Algebraic Data Types in Elixir
blog.appsignal.com
blog.appsignal.com
I must say though, I hate dialyzer. Did I get it wired up at my company? Oh yeah. I made it part of our CI pipeline in my first few weeks. My team and I have liked the help it has given us. But where it falls down is when it’s errors are stupidly obtuse or when it doesn’t catch errors. By construction it is an unsound check: some errant programs will not be caught because Elixir (and Erlang) make it really hard to infer types. The creators wanted something that could check existing Erlang code, [1] so they had to sacrifice soundness for completeness. [2] So often I really want soundness checks, and I have spent many an hour chasing down stupid little type errors that dialyzer doesn’t tell me how to find. So that’s the hate. Overall, though, it does catch a lot of stupid mistakes, so I still appreciate its efforts. :)
[1]: Lindahl and Sagonas, “Practical Type Inference Based on Success Typings.”
[2]: I have a write up on the difference between some of these terms here: https://lambdaland.org/posts/2022-03-02_sound_complete_and_d...
Do you know of a good reference that discusses what types of problems Dialyzer is good for and what problems it isn't good for?
Beyond that… er no, unfortunately. I’ve done many a web search trying to figure out some funky behaviour. :-P
I'm happy to see that the official type annotation system for Ruby is basically hiding them under the carpet by writing them into a separate optional file where they hopefully go to die in the dark. The @spec line in Elixir is just not DRY and inconvenient. Sometimes you fail to match it to the actual function definition because typos happen.
I add an anecdote from the largest Elixir project I've been working on. One of the original developers added @specs to all the modules he developed. The other two of us did that too for many reasons. We're all senior developers but he's more senior and we'd like to see how it turned out. That developer left the project pretty soon, with some appearances now and then. New developers joined the project, mostly junior. After five years no new code has @specs AFAIK.
I remember a couple of cases where static typing would have spared us a half hour of debugging. No type declaration saved us hours of typing and thinking though. I can't think about a single test that we wouldn't have to write if we had static typing because we're testing for functionality, not for input and output types.
Finally, pattern matching in function definition is a great substitute for static typing because it limits the shape of the data that code can work on. There is no way not to have two Person arguments in function with a declaration of
def marry(%Person{name: name1}, %Person{name: name2}) do
IO.puts("#{name1} and #{name2} are married")
end
You can't marry a dog and a cat there, or an int and a string.Some of us want to have the concurrency and resilience of the BEAM with ADTs for data modelling and other static type advantages.
Caramel, gleam and purerl are worthy efforts in this direction. A more production ready half-ass alternative is to use elixir with whatever type advantages you can get.
All projects including elixir started as one man's itch and turned to large communities. It has to start somewhere... Much of what you and I use today wouldn't exist if people only worked what they were paid to do.
This. So many times this. If specs could be inlined it would be so much less hassle (but might hurt readability because of pattern matching)
I'm guilty of regularly copy/pasting @spec from another function and forgetting to change the name, it's very inconvenient.
I wonder how much work it would be to allow type hints and for Elixir to transform them to specs (and maybe guards) at compile time.
Having made the transition in PHP years ago it always feels a bit archaic to write specs again.
Especially as you kind of doing it already when pattern matching a struct parameter.
I disagree. The spec is part of the documentation of the function. I find code with specs is 10x easier to read and safely modify/refactor when you better understand the function contract.
See also: Typed Racket, Haskell where the types are often specified just above the actual implementation. If you don’t like it, I doubt I’ll change your mind. :) I don’t think it’s that bad though.
There are plenty of times when you can make the specs really expressive, much more than just pattern matching (which is great, especially when combined with guards) eg: (on phone, might be a like off on syntax)
@spec find_if([t1()], (t1() -> boolean())) :: t1() | nil when t1: any()
Function types are opaque to pattern matching and you can’t express return types in patter matching either.Yeah, I’m all for strong typing too.
Most specs aren’t as complex as the one I just threw up there. What I really like specs for is to tell me whether or not the function is meant to return a maybe tuple ({:ok, val} | {:error, “err”}) or if it will throw an error. Sometimes that’s not obvious from bigger functions, and it makes refactoring harder.
My ideal would be something like Typed Racket’s gradual typing, which would let you get static types around eg core modules that see a lot of use and where you use the types to check your assumptions but non-core modules can be untyped.
But I get so much from Elixir pattern matching without needing to bother forcing types everywhere that I have a hard time understanding why people feel like they need them so much?
How do you figure out types in a code base you didn't write or don't know? Because when you're working in a code you wrote or have worked on extensively, you have the typespecs in your head, but everyone else doesn't. And I'd even wager there's holes in what you have in your head and thus potentially in the code.
I've worked on Erlang and Elixir code bases, and without typespecs, you spend an inordinate amount of time learning and tracing what the expected types of arguments are and what return types are. That time and effort is completely saved if there are good typespecs. When I read a function, I do not want to spend anytime understanding the types. Typespecs are an absolutely necessary evil in a dynamically typed language. It's part of the tradeoff.
90% of the time the types are simply an extension of the database and enforced strictly at the database layer while everything else is just data pass through anyway (in Ruby, Python, PHP, etc). You understand the database, you understand the application data.
With Elixir you get so much added clarity just from pattern matching and guards that you end up with a Strongly typed code base, just not Statically typed. For an application backed by a relational DB, strong typing is more than enough for almost every use case I've ever come across.
Now, if there's a lot of data work happening inside the application that's not a direct extension of a database then the value of static typing goes up significantly.
I really think it's just one of those areas where people come at problems differently. Some people love it and swear by it. For those people, I think the lack of static typing causes a real gap in their entire approach to programming which translates to frustration and seeing a lot of "this wouldn't have been a problem with static typing..." experiences.
For the people used to dynamic typing, static typing adds a lot of unnecessary complications and headaches without much benefit. Suddenly you have to ensure your types are in sync with your database at all times. You have to enforce them at many different locations in your code base. You've got to define a lot more structure just to translate arguments from web forms, APIs, JSON, etc. If all that overhead saves your bacon later, great...but for a lot of people used to dynamic typing the benefits just haven't been there to outweigh the negative tradeoffs.
Your last paragraph is accurate regarding the tradeoffs of a statically typed language. However, it leaves off the tradeoffs of a dynamically typed language. In a statically typed language with type inference, in the main portion of the system, statically inferred types are a massive benefit and save a lot of heartache. When you call a function, you know its types. Yes, at the boundaries of a program such as I/O and sometimes message passing, things get sticky. Dynamically typed languages help at the boundaries because they loosen things up enough to be helpful. However, they throw out all the benefits in the parts of the system away from the boundaries. I view typespecs as a necessary tradeoff to accommodate static types missing from those parts of the system. In such parts in a dynamically typed language, one basically is coding like you would in a statically inferred typed language and is not taking advantage of dynamic types, so typespecs should be there. I'd argue that abuse of dynamic types away from boundaries should be considered very seriously, same as one would do with macros.
> 90% of the time the types are simply an extension of the database and enforced strictly at the database layer while everything else is just data pass through anyway (in Ruby, Python, PHP, etc). You understand the database, you understand the application data.
Case in point. In a large system, why do I or others need to understand the data at the database layer to develop something many layers above or away from that? There is almost always a transformation from a storage data structure to the in-application and domain data structures. Databases are for storage and state retention, not application logic and data structures. Your 90% of the time applies at the database interaction layer, but it is not the majority of the code.
> I really think it's just one of those areas where people come at problems differently. Some people love it and swear by it. For those people, I think the lack of static typing causes a real gap in their entire approach to programming which translates to frustration and seeing a lot of "this wouldn't have been a problem with static typing..." experiences.
You seem to put it on developers coming to dynamically typed languages from statically typed languages having a "gap" in their approach. When wanting to call a function, I do not see it as a gap in one's skills or experience in not wanting to have read the implementation of that function and the functions it calls. When calling a function, I want to know its name, its argument names and types, the return types, and documentation. I don't see why it's a gap in one's approach to want that. It's almost the first thing you learn when learning software development. I think dynamically typed developers are just used to the overhead of reading function definitions to understand how to call things or they are the ones who wrote or have significantly used the code base.
Gap was a bad choice of words. I just meant a different approach.
I've worked with both dynamic and statically typed languages, as well as Elixir which I consider the right "in between" balance.
In the environments that I've worked in, the devs who were comfortable with dynamically typed languages were extremely productive. The ones that preferred static typing who tried to work in the dynamic languages regularly complained about the lack of static typing and were typically not as productive.
The in the static typing environments, you'd see the reverse. The devs who were more comfortable with dynamic typing were regularly frustrated by the steps they saw negatively impacting their productivity while the devs who preferred the static environment typically seemed happy and complaint free.
I really think it just boils down to how people think about problems. This is entirely anecdotal of course.
And again, I'd wager that "productive" people in dynamically typed languages are more comfortable than productive if one considers bugs introduced by assumptions of types and the unavoidable overhead in reading code implementations to understand call uses. I have seen countless bugs that would have been prevented or at least lessened by typespecs. If that's just a "statically typed perspective", what other process is catching these very real bugs and mistakes in the code? I have paired with people seemingly comfortable with not having typespecs and when asked questions like "how do you know ___?", the answer was usually "I don't" or "I think". Once we figure out the actual types and expected values, again, why not just right them down? Why throw out that knowledge?
The way that I view things is that statically inferred types are a huge boon to the majority of a program abut can be a real pain at I/O boundaries, message passing, and other wings and edges of the software design. With dynamic types, they are a major boon to these I/O boundaries, message passing, and other wings and edges, but they are a major pain point in the rest of the program. My perspective is that whatever type of language you are using, you need to accept but account for its deficiencies.
In a dynamically typed language, I'd wager that 95% of the code doesn't need to be dynamically typed. In that, almost all of the time, you have code that could easily be done in a statically typed language and modeled well with custom types and type aliases like what Elixir provides with `@type`. The choice of using a dynamically typed language provides some freedom at the boundaries of programs. In functional languages like Elixir and Erlang, these happen when side effects take place like file I/O and also in message passing. The choice to use a dynamically typed language does make things a little easier here, but the tradeoff should be that you should think and program the rest of the system as if it's a statically typed language. Given that something like Elixir is not statically typed and especially does not have type inference, the tradeoff is that now you need add typespecs to your code. This is to help prevent type-related bugs and to help communicate the code to yourself and other developers.
> New developers joined the project, mostly junior. After five years no new code has @specs AFAIK.
I'm not sure what your point is here, but that sounds like a negative direction. Dynamically typed code without typespecs increases developer cognitive load, increases the likelihood of bugs, and increases the difficult of on-boarding new developers.
I used to do a lot of Python and heard of Elixir as a marrying of BEAM and dynamic typing, so I started learning it. However, as I used it more, I actually moved to TypeScript and more recently Rust after I found that I actually liked thinking in types, and I truly did miss them from Elixir.
Since dialyzer does "success typing", it will allow code that could work in the happy path, even if it has type issues, which is very limiting. You can make it stricter with `overspecs` and `underspecs`, which will flag if the inferred type is either stricter or looser than the given spec, and these are so close to what I want, but they don't quite work with sum types as in the article here.
The issue is dialyzer considers "too loose" or "too strict" on both function inputs and outputs, but really what you want is to warn only if the function returns more than the spec allows or accepts less than the spec allows. (This is, I believe, covariance and contravariance in types[0].) In other words, you want half of each of underspecs and overspecs.
Consider the example in the article of `@type direction :: east | north | west | south`. Then, it's perfectly valid for a function to return only a subset of those:
@spec common_wind() :: direction
def common_winds(), do: :east
But with `underspecs` this gets flagged. (This would be fine in Rust, for example. If you say a function returns an enum, you don't need it to return every variant of that enum.)On the other hand, this is what you want on the input. If you specify `@spec foo(direction)`, then it better handle every variant of that enum.
The opposite problem is a function that can, in theory, handle more than specified. For example:
@spec func_that_can_handle_null(direction | null) :: boolean
def func_that_can_handle_null(arg), do: ...
@spec my_func(direction) :: boolean
def my_func(arg) do
func_that_can_handle_null(arg)
end
In this case, you want to express to callers of your function that `null` is not acceptable. However, incidentally from the implementation, it is (for now). In this case, dialyzer will infer that the function can handle `direction | null`, and see that your spec only has `direction`, and so the `underspecs` flag will now warn against it! But there's no problem here. The problem function inputs can have is the reverse: if you specify `direction | null` but then don't handle the `null`. That's the `overspecs` flag, but then that will mess you up on the first example!OTP 25 introduced `missing_returns` and `extra_returns`, which is "half" of it, only checking the return side, so I'm thrilled we're finally going in the right direction. But until we have `missing_inputs` and `extra_inputs`, I will continue to think that dialyzer doesn't really support ADTs.
[0] https://docs.microsoft.com/en-us/dotnet/standard/generics/co...
yikes
Elixir is one of many languages that are red flagged in my job filter.
I conditionally allow python where python >= 3.10, and ruby where sorbet is strictly enforced, )perhaps I'll make an exception for dialyzer)
I'm bullish on golang being in my filter for years to come..
Also I feel that people that care about ADTs wouldn't be using Elixir in the first place, so the evangelism feels wasted here.
Some of us who do care are also working in domains where we need really high concurrency, where Elixir frequently is the best tool, ecosystem, etc. for the job. Elixir has a great ecosystem and tooling; I’ve found it to be a joy to work in.
Dialyzer is a must for me; I wrote above that it’s a love/hate relationship, but it’s something. I’d enjoy a more strongly typed language to work with for sure.
This person speaks.. for themselves only.
> perhaps I'll make an exception for dialyzer
If your opinion is to disregard Elixir, then dialyzer shouldn't change your mind. It's pretty ineffective and painful to use.
That said, I'm a big proponent of Elixir; it's a great language and makes me happy to use it.