Introduction
For a Rust/TypeScript/Go developer, Elixir might seem like a strange choice, especially since it has some quirks similar to JavaScript (like ==
and ===
). However, I was always drawn to its syntax and the functional programming aspect was new and intriguing to me.
The first time I tried Elixir was during Advent of Code. I liked it immediately, but after solving 3-5 problems, I started to dislike it. I didn't appreciate the lack of types (only type annotations) and the absence of early returns. But now, I realize I was completely wrong about the language.
Type System
The type system usually determines if I even consider a language. Take Python, for example—it lacks true types and only has type hints. This, along with Python’s other issues (GIL, memory inefficiency, slow execution, async/await complexities, lack of true multithreading, and library inconsistencies), bothers me the most, especially with large codebases.
Elixir is different. Even though it has dynamic types, it doesn't cause as much pain as JavaScript or Python due to two features: immutability and pattern matching.
Immutability
Immutability helps prevent many mistakes common in mutable languages. You can't accidentally pass something that gets mutated by code elsewhere in the app. You also avoid issues with asynchronous mutations because everything is immutable.
immutable = %{foo: "bar"}
reassigned = Map.put(immutable, :foo1, "foo")
IO.puts("Reassigned is #{inspect(reassigned)}")
Task.async(fn ->
:timer.sleep(100)
IO.puts("Immutable is #{inspect(immutable)}")
end)
mutated = Map.put(immutable, :bar, "foo")
IO.puts("Mutated is #{inspect(mutated)}")
Result:
Reassigned is %{foo: "bar", foo1: "foo"}
Mutated is %{foo: "bar", bar: "foo"}
Immutable is %{foo: "bar"}
As you can see, immutable
stayed the same while mutated
changed. Elixir supports object versioning, which keeps memory usage efficient.
Type Guards and Pattern Matching
I dislike type guards in languages without pattern matching; they always seem like a crutch. Elixir has both pattern matching and proper type guards built into the language.
Elixir also does not support early returns. Instead, almost everything is an expression, and the last expression is always the result of a function or parent expression. This encourages splitting logic into smaller functions, which is beneficial.
In JavaScript, you might have something like:
class Human {
iq: number;
constructor(iq: number) {
this.iq = iq;
}
}
interface Bird {
iq: number;
hasWings: boolean;
}
You can write a function like this:
function foo(object: Human | Bird): string {
if ("hasWings" in object) { // you can do this because only Bird has the hasWings field
return `Hi bird. wings=${object.hasWings}`;
}
return `Hello fellow Human! iq=${object.iq}`;
}
Or you can make runtime checks with instanceof
:
function foo(object: Human | Bird): string {
if (object instanceof Human) {
return `Hello fellow Human! iq=${object.iq}`;
}
return `Hi bird. wings=${object.hasWings}`;
}
The Elixir way would be to split them into two functions (Elixir supports function overloading):
defmodule Types.Bird do
defstruct iq: 0, has_wings?: true
end
defmodule Types.Human do
defstruct iq: 0
end
defmodule HumanBird do
def foo(%Types.Bird{} = bird), do: "Hi bird. wings=#{bird.has_wings?}"
def foo(%Types.Human{} = human), do: "Hello fellow Human! iq=#{human.iq}"
end
We can also make runtime checks to match functions:
defmodule Types.Bird do
defstruct iq: 0, has_wings?: true
end
defmodule Types.Human do
defstruct iq: 0
end
defmodule HumanBird do
def foo(%Types.Bird{} = bird), do: "Hi bird. wings=#{bird.has_wings?}"
def foo(%Types.Human{iq: iq} = human) when iq > 100, do: "Hello very smart Human! iq=#{human.iq}"
def foo(%Types.Human{} = human), do: "Hello fellow Human! iq=#{human.iq}"
end
The result would be:
iex(1)> HumanBird.foo(%Types.Human{iq: 90})
"Hello fellow Human! iq=90"
iex(2)> HumanBird.foo(%Types.Human{iq: 101})
"Hello very smart Human! iq=101"
Asynchronous Model
Elixir's asynchronous model feels similar to Golang's. It has green threads (called processes), and all communication is done by sending signals to processes (like channels in Golang, though a process has only one channel). Processes are managed with Supervisors.
Elixir processes are lightweight and can be spawned by the thousands without significant performance hits. This allows you to design highly concurrent and fault-tolerant systems efficiently. Each process is isolated and has its own memory heap, enabling separate garbage collection and ensuring fault tolerance. If one process fails, others remain unaffected.
Processes and Communication
Elixir uses green threads, and all communication is done by sending signals. Unlike JavaScript or Python, Elixir does not have async/await syntax, avoiding function coloring (a big advantage). Instead, you create processes and send them signals by PID or wait for signals to your process. This model allows for powerful abstractions and flexible work with async processes.
Sending a signal to the same process:
send(self(), {:message, "Hello from Self"})
receive do
{:message, m} -> IO.puts(m)
end
Prints: Hello from Self
Supervisors
Supervisors are a key part of Elixir's fault-tolerance and concurrency model. They are processes designed to monitor and manage other processes, known as child processes. The main purpose of a Supervisor is to ensure that if any of its child processes fail, they are restarted according to a specified strategy, maintaining the overall health and resilience of the application.
Supervisors use different strategies to handle failures:
-
:one_for_one
: If a child process terminates, only that process is restarted. -
:one_for_all
: If a child process terminates, all other child processes are terminated and then all are restarted. -
:rest_for_one
: If a child process terminates, the terminated process and any processes started after it are restarted. -
:simple_one_for_one
: A simplified:one_for_one
strategy for dynamically attached children.
A basic example of a Supervisor might look like this:
defmodule MyApp.Supervisor do
use Supervisor
def start_link(arg) do
Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
end
@impl true
def init(_arg) do
children = [
{MyApp.Worker, arg}
]
Supervisor.init(children, strategy: :one_for_one)
end
end
In this example, MyApp.Supervisor
starts and supervises a worker process named MyApp.Worker
. If the worker process crashes, the Supervisor will restart it according to the :one_for_one
strategy.
Process Linking
Process linking in Elixir creates a bidirectional relationship between two processes, ensuring that if one process terminates (either normally or abnormally), the linked process is also terminated.
Creating a Link
A link between processes can be created using the Process.link/1
function or Task.start_link
to start a linked process. For example:
spawn_link(fn ->
# Process code here
end)
Task.start_link(fn -> IO.puts("Started Linked") end) # with Task
Or by explicitly linking two processes:
pid = spawn(fn ->
# Process code here
end)
Process.link(pid)
Use Cases
- Supervisors: Supervisors use process linking to monitor their child processes. If a child process crashes, the supervisor can restart it based on the defined strategy.
- Monitoring: Processes that need to monitor the health of other processes can use linking to be notified of crashes and take appropriate actions.
Process linking allows the creation of hierarchies of Supervisors and processes to ensure fault tolerance.
Not Everything Should Be JavaScript
Another thing that I discovered is that not everything on the client side needs to be JavaScript. For all my latest project, if I needed a UI to interact with users I usually used Next.js
or other React.js
based solutions. This time, I decided to try Phoenix, the default framework for Elixir projects (similar to Ruby on Rails for Ruby, Django for Python, Spring for Java).
Initially, writing the backend was smooth, but creating the client-side code was challenging. As someone used to writing everything in tsx
files, using Phoenix was a significant lift. I had to use cookies, sessions, and write a lot of styles. However, I learned a few things in the process.
We Rely Too Much on JavaScript
Many tasks I typically did in JavaScript could be done with CSS, HTML, and maybe a bit of JavaScript. Tabs, dropdowns, forms, and animations that I previously created with JavaScript were possible without it. I realized that as developers, we've gotten lazy and often overlook existing solutions. I remember the days of jQuery and how easy it was (not without problems ofc) to use. But then came React, Redux, Redux Saga, and other libraries that were so overcomplicated. Now, it seems we're returning to more basic solutions that simply get the job done, such as Svelte, HTMX, Solid, and others.
Phoenix LiveView
Phoenix has a feature called LiveView, and it’s fantastic.
LiveView allows you to create real-time components with server-rendered HTML that updates in real-time as data changes. It eliminates the need for much of the JavaScript boilerplate we often rely on, keeping logic on the backend.
- Built-in PubSub and Channels
- Scalability thanks to the Erlang VM
- Real-time component updates
Here’s a small example of a LiveView component that renders a paragraph with "Initial Value" text and a button that changes the content of the paragraph to "Updated Value" on click:
defmodule LiveviewExampleWeb.FieldLive do
use Phoenix.LiveView
def mount(_params, _session, socket) do
{:ok, assign(socket, :field_value, "Initial Value")}
end
def handle_event("update_field", _value, socket) do
new_value = "Updated Value"
{:noreply, assign(socket, :field_value, new_value)}
end
def render(assigns) do
~L"""
<div>
<p><%= @field_value %></p>
<button phx-click="update_field">Update Field</button>
</div>
"""
end
end
IEX
IEX is Elixir’s interactive shell, similar to Node.js or Python but much more powerful. IEX lets you compile your project and call any function manually.
If you have a problem with a function that performs complex logic, you can easily test it with IEX:
❯ iex -S mix
iex(1)> MyApp.ComplexFunctions.Fun(%User{id: "user-id"})
This is incredibly convenient. You can do this on a running project.
Additionally, you can connect to a remote machine running your application, connect with IEX to a running process, test it, modify the code, and debug.
Conclusion
Writing my project in Elixir was a positive experience. Moving forward, I plan to write all my startup and pet projects in it. Here are several reasons why Elixir + Phoenix is one of the best technologies for general-use projects:
- Dynamic + Strong Typing: This combination allows you to write code quickly without creating much technical debt.
- Real Asynchronicity: Elixir enables distributed calculations, parallel connections, periodic tasks, and eliminates the need to write separate services for complex parallel tasks.
- Abundance of Tools in Elixir: Elixir provides many built-in tools such as key-value stores and scalability features, which reduces the headache of needing separate message brokers or task queues.
- Phoenix Framework: This Django-like framework has everything built-in, plus LiveView, which is incredibly powerful. While I used to prefer building everything using the best available tools (e.g., React.js, Golang, Redis, PostgreSQL), I've come to realize that a $5 WordPress site can generate more revenue than complex setups with multiple microservices and no clients.
- Pattern Matching: After using languages with pattern matching like Elixir and Rust, it's hard to return to Golang with its simplicity and directness. I believe that writing smart and elegant code is more important in solo projects than being direct and simple. It provides satisfaction and fuels enthusiasm, which is crucial when developing something on your own, especially considering the potential of discarding your work.