Part 3: Macros & Error Handling
There are so many interesting and exciting features of Elixir & Erlang that it's been hard to pick what to include in this series. I've picked a couple noteworthy features for this section that help illustrate even further how Elixir is different, and in doing so perhaps challenge some existing paradigms you may be all too comfortable with.
Let it fail
If you've written code in Java, Javascript, Go or any other C-like language, you'll notice how imperative (no pun intended) 'defensive' error handling is to a safe, predictable application. In fact, you probably spend a significant amount of time thinking about and defending against things that could go wrong. If you've written code long enough, you know there is plenty that can go wrong and it can be quite cumbersome and tedious to account for all of the possibilities.
In Javascript you have all of the issues that come with dynamic types, and just to assert your data is what is expected can be enough to keep you busy all day. Luckily Typescript has helped a lot with that, but it still doesn't guarantee I/O comes back as the expected data type.
Even if you don't have to worry about dynamic types you still have to worry about throwing exceptions, logging them, recovering from them, and all of the different code paths unexpected data may lead to. Even if this is simple, explicit and straightforward, like in Go for example, you still have a lot of repetitive, boilerplate code to write and maintain.
Elixir takes a totally fresh and counter-intuitive approach to error handling, and prefer to just 'let it fail'. Sounds crazy, right? Well it is if you were to do that in a lot of languages, but Erlang is different. Erlang uses a concept called 'Supervisors' that act as a sort of container for a process or group of processes.
A supervisor will be notified by the Erlang runtime if a process (lightweight 'green' thread) fails. Depending on the configuration of the supervisor it can simply retry the process one or more times, and even utilize an exponential roll-off so if the process continues to fail it won't continue to retry at short time intervals, hindering the system.
So instead of all that paranoid, defensive coding, you can focus on happy path execution (and still throw traditional errors etc) and let the Erlang VM handle the failures. The process isolation ensures a crash of a single process won't affect the rest of the system so you don't have to worry about bad code execution taking down your entire service.
Supervisors and error handling in Erlang is an interesting study unto itself, and a good challenge to the status quo of defensive error handling. That is not to say you should still not be disciplined about safe and effective handling of unexpected application behavior, but you can spend a little less time preparing for the worst and instead focus on building the best!
Macros
For the uninitiated, Elixir may seem to Erlang what Babel/ES2015+ is to Javascript, like a Coffeescript (yikes) for Erlang. At the surface that's what it may seem like; syntactic sugar for an older language that has some redeeming qualities, much like what has happened to Javascript over the last 5-10 years. Erlang code even runs on a VM, similar to how Javascript runs on V8, and you could even think of WebAssembly as the 'bytecode' that runs on V8, much like how bytecode runs on the ErlangVM.
All of these are fair observations. The Erlang VM is a VM for Erlang like V8 is a VM for Javascript. Javascript/Erlang are the 'outdated' languages, and Elixir is syntactic sugar on Erlang like ES2015+/Babel is to Javascript. Seems similar, right?
The truth is they have one distinct and very significant difference; Babel/ES2015+ is transpiled while Elixir is actually a macro language.
Transpilation
When you write ES2015+ code for your Node/Javascript application, Babel and your build system takes this refined, modern version of Javascript and transpiles (basically transforms it) into the standard version of Javascript. Because Javascript is interpreted rather than compiled, this transpilation step merely transforms the human-readable code. Transpilation does not compile it into bytecode or binary, it remains as source code, just messy, computer-generated source code.
In other words, transpilation is largely superficial, mostly providing developer conveniences and features that haven't actually made it 100% into all JS VMs. There are no performance gains after it is transpiled, unlike with a compiled language where your code is reduced to the most efficient form. Really, it is nothing more than a developer convenience, but can also be highly inconvenient when you have to manually set up and configure all of the tools required just to write code.
Macros
Elixir isn't transpiled. Instead, Elixir is actually a macro-language, meaning the language itself is extensible and customizable. There is no need to 'rewrite' static code; instead you can create specialized macros that offer new language-level behavior not available out-of-the-box.
Personally, I think macros are the one main feature Javascript needed from the start to prevent us from this crazy reliance on transpilation and the vast differences between JS codebases. Instead of polyfills, Babel transforms and browser-support tables Javascript could've used macros to add features like generators or let
and const
. There could be shared, open source macros that could be pulled into projects like normal dependencies instead of Babel transform plugins, and domain-specific macros that may be proprietary or specific to an application or project. Neither would need a 'compilation' step, the behavior is baked into the language.
In fact, I think macros could've solved a majority of the issues in Javascript/NodeJS today, especially in regards to importing modules (CommonJS, ES6 modules, dynamic imports etc) and working across environments (server & browser).
Instead, we use clunky transpilation to make interpreted code we write work for a VM that isn't designed for how we use it. It really doesn't make sense, and if you've worked heavily with setting up Babel, especially in multiple environments, it'll become obvious why true macro support is vastly superior.
Do not make the mistake of thinking that Elixir is to Erlang what Babel is to Javascript. One is based on a fragile, hacky ecosystem of ever-changing requirements and preferences, and the other is central to the design of the language itself.
Example
If you've done any significant API work, especially REST/GraphQL, you've probably noticed how repetitive it can be. Create the database model, set up all the basic CRUD operations, add pagination and filtering, validate changesets etc. Every new resource requires a ton of files and boilerplate and if you forget one thing you have to spend time tracking down exactly what you missed. Combine that with adding new resources regularly and a significant portion of your job is spent creating the same boilerplate code just with different fields.
Sure, you can use code generation to simplify this process, which will create all the static files you need. That doesn't change, however, the amount of boilerplate code you still have to maintain and debug if something goes wrong. Also, if you need to completely change the way you handle resources (add a business rule layer to updates, for example), you now need to go update each individual resource, by hand.
What if you could just write a standard macro that takes your database model and automatically creates all of this boilerplate code for you, implicitly? What if instead of writing out each of those functions by hand, copy and pasting from somewhere else in your code, you can write a single macro that automatically adds all of this behavior for you without all of the code? Instead of your list, single, create, update, and archive/delete operations on each individual resource, you simply put use MyModule.CRUDMacro
at the top of your file instead and call it a day?