Typing lists and tuples in Elixir
elixir-lang.org
elixir-lang.org
Again, I definitely trust them to get it right in the long term, but in the meantime, the progress has been a bit confusing to me.
- Map keys (called with '.') are checked at compile time.
- Using comparison operators with different types causes a warning.
I may be forgetting something.
We need to type every data type and every function, so the type system will be rolled out over a long period of time.
The 1.17 release meant that we now have a gradual type system, which runs in every code being compiled, but it only supports a handful of types (including the dynamic one). The full list of supported types and examples of typing violations it can now detect is on the announcement: https://elixir-lang.org/blog/2024/06/12/elixir-v1-17-0-relea...
There is no support for type annotations, that comes in a later stage. The overall stages have been described in an earlier article (and I believe also in the paper): https://elixir-lang.org/blog/2023/06/22/type-system-updates-...
That's how it normally goes with gradual type systems for existing languages, I think. The first step seems to be almost always adding a type checker that doesn't do anything in particular other than handling untyped code. Since being able to handle untyped code makes a type system gradual, announcing Elixir as "gradually typed" when this milestone is reached seems justified. After that, you're free to improve the type system and type checker(s), improve type inference, add specialized syntax, improve typed/untyped interactions, cover more language patterns, and so on. MyPy for Python also started without support for many things that were added later (and it's still being actively developed ten years later).
Having a separate NonEmptyList type might seem like a good idea in theory, but in my experience, it leads to code that is significantly more complicated.
In my view, you’re moving the potential for failure to a different place (the constructor), rather than changing some fundamental property or introducing new complexity.
Is it handling the construction of these types you find complicated? And is it simply not worth the guarantees?
type NonEmptyList<'t> = 't * 't list
But this cannot be passed to any function that expects a List<'t>. This is odd though, since intuitively, all non-empty lists are lists.See Rich Hickey's "Maybe Not" talk
OOP solution is to use inheritance. Typical ML solution is to use type-classes.
F# sits in an awkward middle-ground where neither is a perfect fit.
I believe that dependently-typed languages solve this more elegantly.
There's also the syntactic inconvenience of wrapping at construction, where in theory the compiler could figure it out for you.
For example:
let xs = 1 :: 2 :: 3 :: []
Here xs is non-empty, but we must tell the compiler: let xs = 1, 2 :: 3 :: []
TypeScript does a better job here (although a great cost!)What you end up with is massive code duplication or lots of extra function calls:
xs
|> NonEmptyList.toList
|> List.map (fun x -> x + 1)
|> NonEmptyList.unsafeFromList
(I say this all as someone who really likes F#)Pattern matching on a non-empty list is also inelegant, because it is implemented as a tuple, which creates a leaky abstraction.
list(a) = empty_list() or non_empty_list(a)
So you should pass non-empty lists everywhere a list is expected. But you can’t pass a list where a non-empty one is expected.But overall, you are right: our concern is exactly all of the extra function calls that may now suddenly become necessary (and the tension mentioned in the article). We will review our design decisions as we keep on rolling out the type system!
e.g.
list(a) = empty_list() or singleton_list(a) or two_or_more_list(a)Yes, type classes can "work" to help a NonEmptyList degenerate to a normal List of some sort, if the function accepting the list accepts the type class instead of a hard-coded List. Unfortunately, at least for this exact task, taking hard-coded types is pretty common. I've sometimes wondered about the utility of a language that provided all of its most atomic types solely as typeclasses within its standard library, so that calling for a "List a" or "[a]" automatically was turned into the relevant type class.
Inheritance doesn't actually work here. I assume you mean inheriting a NonEmptyList from some sort of List, from the perspective of a user facing a language that has a standard List and they want to create a NonEmptyList that things taking List will accept. Unfortunately, that is a flagrant violation of the Liskov Substitution Principle and will create architecturally-fragile code.
Compilers can't enforce the LSP (with anything short of the dependently typed code you mention), so you can bash out a subclass that will throw an exception if you try to take the last element out of a NonEmptyList or violate the rules some other way, and if you pass your new NonEmptyList to something that happens to not do anything broken, you may get away with it, but by the standards of OO theory you're definitely "getting away" with something, not solving the problem.
I haven't studied this extensively beyond just thinking here for a moment, but I don't think you can LSP-legally go the other way either. A subclassed List can't revoke a parent's NonEmptyList property that the list is guaranteed to not be empty. Again, you can bash the methods into place to make it work, but as this is a very basic standard library sort of thing for a language it really needs to be right.
Edit: Yes, it's certainly illegal. You can take a List inherited from the NonEmptyList, have it be empty, but you have to be able to pass it to something accepting a NonEmptyList, but it will then be empty. So you can't LSP-legally inherit either way.
(This is one of the "deep reasons" why inheritance is actually not a terribly useful architectural tool. It technically breaks really, really easily... like, probably most non-trivial uses of inheritance in most code bases is actually wrong somehow, even if never happens to outright crash. We tend to just code past the problem and not think about it too hard.)
Shouldn't it go the other way?
All NonEmptyLists are Lists, but not all Lists are NonEmptyList.
So NonEmptyList inherits from List
A NonEmptyList promises that its .Head method will always produce a value. An inherited List can not maintain that property, it must add either an error return or a possible exception (which is the same thing from this point of view), and so violates the LSP.
A List promises that if it has an element, you can remove it and have another List, whether by mutation or returning a new List. A NonEmptyList breaks that promise. If that sounds like a "so what", bear in mind that "removing an element" includes things like a "Filter" method, or a "split" method, or any of several other such methods beyond just iteration that a List is likely to have that a NonEmptyList is going to need a different type signature and/or exception profile to implement properly.
You could define a bare-bones superclass for both of them that allows indexing, iteration, appending, and a length method, without much else, and that does work. However, if you start trying to get very granular with that, across more data structures, you'll start to need multiple inheritance and that becomes a mess really quickly. There's a reason that, for instance, the C++ STL does not go the "inheritance hierarchy" route for this stuff.
Like I said, inheritance done properly is really restrictive. We often do a lot of sweeping under the rug without even realizing it, and that "works" but it still eats away at the architecture, all the more so if the people involved don't even realize what they are doing.
NonEmpty always has an element.
List<'t> has a function TryUncons that gives Option<'t * List<'t>>
List<'t> does not have a method Uncons.
We can define TryUncons for NonEmptyList<'t> in terms of Uncons, specifically:
this.TryUncons() =
this.Uncons()
|> SomeIt would be an odd "object oriented language" list that lacked such things, e.g., https://docs.oracle.com/javase/8/docs/api/java/util/List.htm... . We're in OO land here, not FP land.
No. Removing an element from a NonEmptyList returns a List. LSP is respected when NonEmptyList is a List.
I don't follow. Remove the head from a NonEmptyList and the tail will be a List. It might not be a NonEmptyList, but that's not the contract of List.
A general confusion of mine in Elixir is generally how libraries and functions treat errors. There's the common idiot of returning either `{:ok, ____}` or `{:error, ____}`, but what can be inside the error tuple is not always clear. The other thing is that sometimes a function can both throw an exception and also return a success tuple. Such cases are confusing to handle, and there's a large gap between handling cases like that and the philosophy of "let it crash", which I think is preached a little looser than it should actually be practiced.
I do like F#'s way of disambiguating the two situations. The only issue I have in F#, which actually exists in every language that I know of that has exceptions, is that there is no way to know, up front and clearly, what exceptions can be thrown by a given function. This is particularly frustrating in F#, which has fantastic pattern matching for exceptions. I wish there was exhaustive pattern matching in F# for exception handling, such that it would warn you that you have an unhandled exception in a try/with expression (https://learn.microsoft.com/en-us/dotnet/fsharp/language-ref...) but of course would allow for wildcard patterns.
IMO the problem is proper exception handling with checked exceptions and wrapping each function (or at least small blocks) in try catch is just so insanely verbose that even though it is possible to get error handling as good as something like Rust, nobody actually does it in practice.
That's the result of Elixir being dynamic and not having built-in monads. You always have to check the docs/code.
> The other thing is that sometimes a function can both throw an exception and also return a success tuple.
I would say this is just poor design (though maybe someone could point me to an exemplary use of this?). As I see it (which is from how I've read it described and how most good lib do it):
- If function can error and the caller can do something about it, return :ok/:error.
- If the caller can't do anything about the error, raise.
- If the function can't fail, return a raw value.Even in a language like F#, just becauase a function returns an `option` type doesn't mean it won't also throw an exception sometimes. However, I don't necessarily think pure functional languages solve this either. If a Haskell function returns an option, you have little idea as to where the error originated. There are error types such as result types, which F# has as well, but then that's basically back to exceptions, perhaps even as a more limited form.
I'm curious if there's a language that's really nailed error handling. Erlang/Elixir have their supervisors and functional languages have pattern matching on option types, result types, and exceptions, but surely there's a way to improve on that.
> If we get rid of this limitations, we could define head as follows:
$ non_empty_list(a) -> a
def head([head | _]), do: head
In this case, I ain't sure what the typespec is really contributing here:- We can already infer the input type, thanks to Erlang/Elixir baking pattern-matching into function signatures
- We can already infer the output type, because there's only one possible output and it's coming directly from the input
This is exactly the problem I hit the last time I tried to chase down the "typespec ALL the things!" path with Dialyzer: the typespecs are just restating what's already obvious from looking at the function, making them redundant at best. Yeah, having a summary of the input and output types is valuable for more complex functions, but it's already a common Erlang (and Elixir, by descent from Erlang and Ruby) best practice to break down complex functions into smaller, simpler units, at which point the typespecs lose value again.
I'm probably missing something here, though - and maybe a more complicated example would better illustrate the value these typespecs add.
I'm curious to learn more, but I can't shake a feeling of vague trepidation here.
This article really speaks to how they're thinking about the costs of the type system, so that you mostly get benefit, and that's great.
It doesn’t seem that way to me at all. The main pitfall static typing guards me from is runtime errors that can be easily avoided, guards don’t really help there.
If I do
def double(num) when is_number(num) do
num * 2
end
I could have some call double(“4”) in my code somewhere, and if that’s reached it would throw a FunctionClauseError and crash the process. I don’t want to be able to do that, I want the compiler to scream at me when I write double(“4”) and not let me do it, the guard is doing nothing of the sort.The optimal solution, then, is for the compiler to be smart enough to recognize that there's a guard and to preemptively enforce it at each callsite. You shouldn't need static typing for this, because the compiler should be able to figure out "oh, there's an is_number guard here, lemme whip up a type constraint for that".
Hell, the even-more-optimal solution would be to not need the guard in the first place, since the * operator already implies an is_number(num) constraint.
It sounds to me like you are describing static typing. As far as I know, type inference from patterns and guards is one of the features of the static type system being developed for Elixir right now [1].
> Hell, the even-more-optimal solution would be to not need the guard in the first place, since the * operator already implies an is_number(num) constraint.
The most optimal solution in my opinion would be a type annotation for the function, so that you do not need to write a guard which adds a runtime overhead just to verify a type that you know you want to always be the same anyways is correct.
Type inference from the `*` operator sounds interesting, unfortunately unlike other languages (like Gleam which has *. +. etc.) Elixir does not have a separate set of operators for floats and integers, so you would not be able to infer which type of number the variable needs to be.
If it were up to me, Elixir would not have pattern matching via functions and instead allowed for type annotation in the function head, I think the matching with multiple function clauses is a lot less readable than just having one function with a big case statement at the top level – of course no way that will change, it is how it is.
[1] https://elixirforum.com/t/full-static-type-inference-of-set-...
Elixir doesn't care; it'll convert to a float if necessary (namely, if one of the arguments is a float):
iex(1)> 1 * 1.0
1.0
This is identical to the behavior of e.g. Julia: julia> 1 * 1.0
1.0
> The most optimal solution in my opinion would be a type annotation for the functionMy point is that the type annotations are redundant, at least in this particular case; the acceptable types are already obvious from the function definition itself.
Given TypeScript’s popularity, it’s clear that developers really appreciate types. Honestly, the lack of types is what eventually drove me away from Clojure.
But Erlang and by extension Elixir are a hard sell unless you are writing a system like Whatsapp.
https://survey.stackoverflow.co/2024/technology#2-web-framew...
This is all unfortunate. I do think the BEAM based stacks are underrated for applications outside of communications. Many of the same traits of resilience and resource utilization designed to facilitate communications systems actually apply to web apps and APIs, too. Elixir is very expressive and a good fit for writing business applications like accounting related software (what I'm currently working on). But... you have to think you can arbitrage those operational advantages into an overall competitive advantage... and that's a tough sell especially because it involves a lot of speculation which doesn't play out until you're getting to the end of a project.
I wish there was more of an embrace in the broader ecosystem rather than the focus on LiveView.