I learned Elixir a few weeks ago as a quarantine self-improvement project and pretty much loved it. There are some warts, as in any language. As someone who's primarily worked in Go and Java in the past and has also been learning Rust I don't super love the optional typing thing, and I end up missing higher-level data constructs like interfaces and traits.
But there are some great language features, like guards and pattern matching, that are hard to give up when you go back to other languages.
Plus it's great to have OTP goodies like GenServer at your fingertips if you run into performance bottlenecks (which you may not!). The OTP APIs are a bit weird coming from other languages but not too bad.
Other things I've liked:
1. Ecto is simply the best DB library I've encountered in any language. I'd almost recommend learning Elixir just to be able to use Ecto.
2. Plug provides great HTTP ergonomics highly reminiscent of Go's context-based middleware approach. Having direct access to the full request/response lifecycle is a win.
3. Phoenix is nice because it's essentially just Plug with some convenient helpers on top. Strikes a really nice balance between configuration and convention by letting you use only what you need. Haven't tried LiveView as I'd prefer to handcraft my own JS but probably worth a shot.
4. Absinthe is the best GraphQL framework I've encountered after many others in other languages left me completely cold.
> I don't super love the optional typing thing
It might not be a plus if you're writing a web-app, or some other kind of easily-restarted "shared-nothing business layer" system. But get deep-enough into absorbing the Zen of Erlang (i.e. by trying to write a big-deal telecom system; or by working on a distributable Erlang DBMS like CouchDB), and you'll come to appreciate it. Specifically, you'll realize that it'd actually be impossible for the Erlang runtime system to support hot-code-reload plus automatic distribution plus static typing, all at once. One of them has to give. (I think this is one of those universal "choose only two properties" triangles, like CAP, though I don't think this one has a name.)
If you have a "living" distributed system—one that you don't bring down entirely for each upgrade—then inevitably you'll hit a situation where a node with the fresh new version of a module (V3, let's say), tries to use that module to send a V3 message to a node that is still only running V2 of the module. In a distributed system with static typing, that'll inevitably crash the V2 node—unless you did a whole extra "V2.5" rollout step to first teach V2 about the wire-format of V3 and how to handle it.
Much of the point of Erlang/Elixir's user-facing "data architecture" design—e.g. using partial destructuring pattern-matching (unwrapping tuples one layer at a time) instead of uniquely tagging messages with message-type UUIDs like COM/CORBA—is to "automatically" cope with this, allowing your "living" distributed system to interoperate in the face of cross-node module-version heterogeneity, without having to write explicitly backcompat "also recognize the previous/next version of the message" code during the rollout period.
Every Erlang term sent in a message is, in some sense, like a little extensible file-format; and the tools you're given to "parse" the term—when you use them idiomatically—give you forward-compatible "parsing." As long as you don't validate that a term has a specific "deep" structure corresponding to some static type, then it doesn't matter if your module-V2 actor has actually received—and is now holding onto—a module-V3 parameter in its state. It won't know what to do with it, but nor will that cause any problems as long as it doesn't poke too hard at it. It can even keep hold of it, storing it away for later in its state generically, without understanding exactly what it's received. It'll be there in the state for when the module upgrades to V3, and suddenly cares about that property.
(OTP's supervision system also comes into play here, ensuring that any unhandled edge-cases of this term "parsing" just become temporary restarts of the leaf-actors that receive them, without affecting the stability of the node as a whole. The network upgrade completes; the affected actors come back up running the new module version; the system continues.)
That’s all true, but I think a Typescript-like system would work really well here. Typescript is very tolerant of the data being different than the type definition, and it doesn’t know or care if your data actually matches the type definition at runtime.
It would be up to the developer to account for different versions of the data when writing out type definitions, and the worst case is that you try to access a property that doesn’t exist or is the wrong type, and it crashes and causes the process to get restarted like what already happens in Erlang.
So it wouldn’t be a guarantee of correctness just like Typescript isn’t, but it could still offer a lot of safety assuming you get the type definition a right.
It's static typing that Erlang can't complete-the-triangle on; not typing generally.
The only problem is that "offline" type-checking like this does nothing to solve one of the main use-cases/pain-points where Erlangers want types (or, at least, think they want types): in the messages that actors receive. You can't make any sort of a type assertion about what other actors in the system are allowed to send this actor, and get that validated; because "other actors" in a distributed system necessarily include ones that aren't even part of your present codebase!
I have a philosophy about this—not sure where I picked it up, but I think it hews to the Zen of Erlang quite well:
If you already know the type of a message, then by definition, it's not a message any more, but just a regular data value. A message is an OOP concept (and Erlang is an OOP language, where processes are the "objects.") An OOP "message" is a piece of data the meaning of which is up to the interpretation of the recipient; where that interpretation can change as the recipient's internal state changes. The whole point of the "receiving a message" code that you write in an Erlang actor-process, is to allow you to do custom logic for that interpreting part. To use the value itself, in making the decision of what the value is.
In fact, I would extend that: the whole point of Erlang as a language is to do that "interpreting" part. Once you know what something is and have put it into a canonical validated structure, you may as well hand it off to native code (using e.g. https://github.com/rusterlium/rustler). If you think of native code as being a pure domain of strongly-typed values, then picture Erlang as the glue that lives in the ugly world of "not yet typed" values, making decisions under partial-information conditions on what types to try to conform received messages into, before they can enter that pure strongly-typed domain. That's Erlang's idiomatic use-case! (You can tell, because using it for that produces absolutely beautiful code; whereas using it to do e.g. crypto math, produces an abomination.)
Which is all to say: the interpretation (or, if you like, constraint) of a message into a typed value is a Turing-complete operation; and the logic for doing so is best represented as an Erlang program. Erlang doesn't need a type system for messages; Erlang is a type system for messages. :)