6 ms·
Typing lists and tuples in Elixir
- munchler 2y agoF# has both a `head` and `tryHead` function to handle lists that may or may not be empty. In general, `tryFoo` is a good pattern for naming functions that might fail. 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.
- truculent 2y agoHow does it make it 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?
- greener_grass 2y agoThe intuitive definition of NonEmptyList is: 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#)
- munchler 2y agoYes, this is exactly what I was getting at. One ends up needing an explicit `toList` helper function that converts a non-empty list into a plain list. Pattern matching on a non-empty list is also inelegant, because it is implemented as a tuple, which creates a leaky abstraction.
- greener_grass 2y agoF# actually does have a good solution for pattern matching here, which is Active Patterns.
- josevalim 2y agoWe (Elixir) would rather define lists on top of non empty lists: 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!
- yawboakye 2y agofor when one expects a list to be non-empty, i think there’s strong argument in favor of an enforcement from the type checker, given that the prove will very likely be necessary. if not in the application code then in the tests.
- greener_grass 2y agoDoes this break down if we want some other types? e.g. list(a) = empty_list() or singleton_list(a) or two_or_more_list(a)
- jerf 2y ago"OOP solution is to use inheritance. Typical ML solution is to use type-classes." 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.)
- aloisdg 2y agoAs a fellow F# dev currently learning Elixir, this kind of thing is a burden
- bmitc 2y agoJust to clarify for the crowd though. In F#, `List.head` throws an exception when it fails whereas `List.tryHead` returns an `option`, which returns `None` when it fails instead of an exception. 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-reference/exception-handling/the-try-with-expression https://learn.microsoft.com/en-us/dotnet/fsharp/language-ref...) but of course would allow for wildcard patterns.
- IshKebab 2y agoJava has checked exceptions and they are used, e.g. in Android. But it seems to be the exception (heh) rather than the rule. 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.
- sodapopcan 2y ago> There's the common idiot of returning either `{:ok, ____}` or `{:error, ____}`, but what can be inside the error tuple is not always clear. 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.
- ku1ik 2y agoI really respect Elixir core team’s approach to adding gradual typing to the language. They don’t rush it. They didn’t put too much focus on syntax so far (I’d argue the syntax in many cases is less important than foundations) and instead they focused on soundness of the system. With each new Elixir version the compiler is getting smarter, catching more bugs. Not hugely smarter, but smarter enough that I feel safer. Looking forward to Elixir 1.18!
- jonnycat 2y agoI'm not sure... I'm a huge Elixir fan and I trust José to build a great solution, but I've found the rollout to be a bit confusing. There was the announcement that "Elixir is now a gradually typed language" prior to 1.17 - but it seems that most of the changes were behind the scenes, and 1.17 largely didn't expose user-facing type errors or warnings. 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.
- sodapopcan 2y agoThe wording was a little odd, but there are certainly user-facing errors in 1.17, namely: - Map keys (called with '.') are checked at compile time. - Using comparison operators with different types causes a warning. I may be forgetting something.
- josevalim 2y agoThanks for vote of confidence! 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-released/ 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-research-dev/ https://elixir-lang.org/blog/2023/06/22/type-system-updates-...
- btbuildem 2y agoI've always viewed Erlang (and by extension, Elixir) as safe from these types of improvements. Maybe I don't have enough experience in these languages, but having guards, and the different approach to conditionals seemed to do away with most of the pitfalls usually "safeguarded from" by the caution tape and excessive road signage of type systems. I'm curious to learn more, but I can't shake a feeling of vague trepidation here.
- rkangel 2y agoErlang and Elixir have long had "dialyzer" as a type checker. The problem is that it had some severe limitations in approach (and implementation) and so the cost/benefit ratio wasn't great. 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.
- __jonas 2y ago> having guards, and the different approach to conditionals seemed to do away with most of the pitfalls usually "safeguarded from" by the caution tape and excessive road signage of type systems 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.
- yellowapple 2y ago> 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.
- 29athrowaway 2y agoErlang is an influential language, it has its uses. But Erlang and by extension Elixir are a hard sell unless you are writing a system like Whatsapp.
- SoftTalker 2y agoErlang/OTP is the nicest environment for building applications that I've ever experienced. I agree though that it's not popular and I've never actually been paid to use it, with the exception of some small projects that were completely under my control.
- scop 2y agoI build web apps. Basic CRUD stuff with a little bit of business logic sprinkled in. Elixir/Phoenix has been an absolute pleasure to use.
- doakes 2y agoShouldn't be a hard sell to developers. According to this year's StackOverflow survey, Phoenix is by far the most admired web framework (10% above the second most popular). Elixir is the second most admired language, behind Rust. https://survey.stackoverflow.co/2024/technology#2-web-frameworks-and-technologies https://survey.stackoverflow.co/2024/technology#2-web-framew...
- insane_dreamer 2y agoThe questions in that survey are poorly worded so as to make the results ambiguous, since they confound 1) languages in which X person has worked with in the past year, with 2) languages in which X person would like to work on next year. These express two very different concepts: 1) popularity in the workplace, 2) developers' personal preferences. (Neither of them have to do with the most "admired".
- doawoo 2y agoI disagree entirely. Having written everything form little tools for my own use to entire linux based firmware for small embedded machines, Erlang and Elixir are powerful tools. I genuinely find Elixir fun to write in a way no other language manages to match.
- yellowapple 2y agoLooks nice; I dig the $-prefixed type annotations. I also like that the types boil down to familiar conventions like atoms / atom-tagged tuples under the hood; that's exactly how I'd expect an Erlang/Elixir type system to work, and is exactly how I already tend to use tagged tuples. > 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.
- ccanassa 2y agoI love that the creators are considering adding gradual types. 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.