Elixir for TypeScript Developers
Learn Elixir from a TypeScript perspective: pattern matching, immutability, processes, and functional programming.
Learn Elixir from a TypeScript perspective: pattern matching, immutability, processes, and functional programming.
Elixir is a functional programming language built on the Erlang VM (BEAM), designed for building scalable, fault-tolerant distributed systems. If you’ve been writing TypeScript, you know static types, async/await, and immutability helpers. Elixir is immutable by default, abandons static types for pattern matching and guards, and replaces callbacks/promises with lightweight processes and message passing. It’s not a drop-in replacement for TypeScript—it’s a different model—but the functional mindset carries over. Elixir 1.16 is current (released April 2024). Install it via:
# macOS
brew install elixir
# or use asdf
asdf install elixir 1.16.0
# Create a new project
mix new myapp
cd myapp
# Interactive shell (like node REPL)
iex
# Run a script
elixir myapp.exs
# Run tests
mix test
# Build for production
mix escript.build
# Add a dependency
mix deps.add http_client
# lib/hello.exs — entry point
defmodule Hello do
def greet(name) do
"Hello, #{name}!"
end
end
IO.puts(Hello.greet("World"))
mix.exs is your package.json. Dependencies go in deps list. Hex.pm is to Elixir what npm is to TypeScript—the package registry. The standard library (Kernel, Enum, String, etc.) is smaller than Node.js by design; you’ll reach for packages more often.
TypeScript has destructuring; Elixir has pattern matching. It’s the same idea, but Elixir uses pattern matching for control flow, function dispatch, and data extraction.
# Simple binding
{a, b} = {1, 2}
# a = 1, b = 2
# Nested matching
{:ok, user} = {:ok, %{"name" => "Alice", "age" => 30}}
# user = %{"name" => "Alice", "age" => 30}
# Function clauses (overloading by pattern)
defmodule Auth do
def login({:ok, user}), do: "Welcome, #{user}"
def login({:error, reason}), do: "Login failed: #{reason}"
end
Auth.login({:ok, "Bob"}) # → "Welcome, Bob"
Auth.login({:error, "invalid"}) # → "Login failed: invalid"
# List patterns
def sum_list([]), do: 0
def sum_list([head | tail]), do: head + sum_list(tail)
# Map patterns
def greet(%{"name" => name, "age" => age}) do
"#{name} is #{age} years old"
end
TypeScript equivalent would require if/else chains and explicit type guards. Elixir makes it declarative.
In TypeScript, you use const to signal immutability (weakly enforced). In Elixir, everything is immutable by default. You can’t mutate; you return a new copy.
# This looks like mutation, but it's not
x = 1
x = x + 1 # x is rebound to a new value; the old binding is dead
# Lists and maps are immutable
list = [1, 2, 3]
new_list = list ++ [4] # original list unchanged
# list = [1, 2, 3]
# new_list = [1, 2, 3, 4]
# Map update syntax (returns a new map)
user = %{"name" => "Alice", "age" => 30}
updated = %{user | "age" => 31}
# user = %{"name" => "Alice", "age" => 30}
# updated = %{"name" => "Alice", "age" => 31}
# Shadowing is normal (rebinding a variable)
message = "hello"
message = String.upcase(message) # message is now "HELLO"
This eliminates entire categories of bugs: you never accidentally mutate shared state.
The pipe operator |> threads a value through a series of functions. It’s like method chaining, but for pure functions.
# Without pipe (hard to read left-to-right)
result = Enum.map(String.split(data, ","), &String.trim/1)
# With pipe (flows top-to-bottom)
result =
data
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.filter(&(byte_size(&1) > 0))
# Define a custom pipeline
defmodule Pipeline do
def transform(data) do
data
|> String.downcase()
|> String.split()
|> Enum.uniq()
|> Enum.sort()
end
end
Pipeline.transform("Hello WORLD Hello") # → ["hello", "world"]
TypeScript has |> (stage 2 proposal) or libraries like Ramda; Elixir has it built-in and idiomatic.
| TypeScript | Elixir | Notes |
|---|---|---|
number | integer / float | Arbitrary-precision integers; floats are IEEE 754 |
string | string | UTF-8 encoded; "string" or 'charlists' |
boolean | boolean | true and false (nothing is falsy except false and nil) |
any / unknown | any (dynamic) | No static types; pattern matching acts as a guard |
object | map | %{"key" => value} or %{key: value} (atom keys) |
Array<T> | list | Linked list, [1, 2, 3]; immutable |
Set<T> | MapSet | MapSet.new([1, 2, 3]) |
Promise<T> | Task / Supervisor | Lightweight processes and message passing |
Error | Exception / tuple | Use {:ok, result} / {:error, reason} tuples; exceptions for truly exceptional cases |
Elixir doesn’t have TypeScript’s static types, but pattern matching and guards enforce type contracts at runtime.
Atoms are constants where the name is the value. They’re used for tags, error reasons, and status codes.
# Atom literals
:ok
:error
:not_found
# Common pattern: tagged tuples
def fetch(id) do
if id > 0 do
{:ok, %{"id" => id, "name" => "User"}}
else
{:error, :invalid_id}
end
end
# Pattern match on the atom
case fetch(1) do
{:ok, user} -> IO.puts("Found: #{user["name"]}")
{:error, :invalid_id} -> IO.puts("Invalid ID")
end
This replaces TypeScript enums for many use cases and is more concise than exception-based error handling.
TypeScript uses async/await and promises; Elixir uses lightweight processes and message passing (the Actor model).
# Spawn a process
pid = spawn(fn -> IO.puts("Hello from a process") end)
# Send a message to a process
send(pid, {:hello, "world"})
# A process that receives messages
defmodule Counter do
def loop(count) do
receive do
:increment ->
IO.puts("Count: #{count + 1}")
loop(count + 1)
:stop ->
IO.puts("Stopped")
end
end
end
counter = spawn(Counter, :loop, [0])
send(counter, :increment) # → Count: 1
send(counter, :increment) # → Count: 2
send(counter, :stop) # → Stopped
Processes are lightweight (millions can run on one machine). Unlike async/await, each process has its own stack and mailbox.
For simpler async cases, use Task:
# Fire and forget
task = Task.async(fn -> expensive_calculation() end)
# Wait for the result (with timeout)
result = Task.await(task, 5000) # waits up to 5 seconds
# Or use Task.await_many to wait for multiple
results =
[expensive_calc_1(), expensive_calc_2(), expensive_calc_3()]
|> Task.await_many(5000)
Elixir processes can crash. Supervisors restart them. This is the “let it crash” philosophy—don’t defensive code, let the supervisor handle restarts.
defmodule MyApp.Supervisor do
use Supervisor
def start_link(init_arg) do
Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
end
@impl true
def init(_init_arg) do
children = [
{MyApp.Worker, []},
{MyApp.Server, []}
]
Supervisor.init(children, strategy: :one_for_one)
end
end
# If MyApp.Worker crashes, supervisor restarts it
# :one_for_one = restart only the crashed child
# :one_for_all = restart all children if one fails
TypeScript has no equivalent; you’d write explicit retry logic.
Elixir discourages exceptions for control flow. Instead, use tagged tuples.
# The Elixir way: return {:ok, result} or {:error, reason}
def divide(a, b) do
if b == 0 do
{:error, :division_by_zero}
else
{:ok, a / b}
end
end
# Pattern match the result
case divide(10, 2) do
{:ok, result} -> IO.puts("Result: #{result}")
{:error, :division_by_zero} -> IO.puts("Cannot divide by zero")
end
# Or use with (sequential error propagation)
result = with {:ok, a} <- fetch_value(1),
{:ok, b} <- fetch_value(2),
{:ok, sum} <- add(a, b) do
{:ok, sum}
else
{:error, reason} -> {:error, reason}
end
# Exceptions exist for truly exceptional cases
defmodule PaymentError do
defexception message: "Payment failed"
end
# Raise an exception
raise PaymentError, message: "Card declined"
# Rescue it
try do
process_payment(amount)
rescue
e in PaymentError -> IO.puts("Error: #{e.message}")
end
The with construct replaces nested if/else or promise chains. It’s cleaner and more declarative.
TypeScript has Array methods; Elixir has Enum (eager) and Stream (lazy).
# Enum — eager evaluation
list = [1, 2, 3, 4, 5]
Enum.map(list, &(&1 * 2)) # → [2, 4, 6, 8, 10]
Enum.filter(list, &(&1 > 2)) # → [3, 4, 5]
Enum.reduce(list, 0, &(&1 + &2)) # → 15
# Stream — lazy evaluation (good for large datasets)
stream =
1..1_000_000
|> Stream.filter(&(rem(&1, 2) == 0))
|> Stream.map(&(&1 * 2))
# Nothing computed yet; only evaluated when consumed
Enum.take(stream, 5) # → [4, 8, 12, 16, 20] (only 5 items processed)
# Function composition
defmodule Math do
def double(x), do: x * 2
def add_ten(x), do: x + 10
end
# Create a composed function
composed = fn x -> x |> Math.double() |> Math.add_ten() end
composed.(5) # → 20
# Or use Function.compose/2
add_then_double = Function.compose(&Math.double/1, &Math.add_ten/1)
# Closures capture variables from their environment
def make_multiplier(factor) do
fn x -> x * factor end
end
times_five = make_multiplier(5)
times_five.(10) # → 50
# Without guard
def can_vote(age) do
if age >= 18 do
:yes
else
:no
end
end
# With guard — cleaner and more declarative
def can_vote(age) when age >= 18, do: :yes
def can_vote(_age), do: :no
# Guards in pattern matching
def process({:ok, value}) when is_binary(value) do
String.upcase(value)
end
def process({:ok, value}) when is_integer(value) do
value * 2
end
# Multiple guards with and/or
def classify(age) when age >= 18 and age < 65, do: :working_age
def classify(age) when age >= 65 or age < 0, do: :special
false and nil are falsy. 0, "", [] are truthy."string" is UTF-8 binary; 'string' is a list of codepoints. Most code uses strings.+ is really Kernel.+/2. You can pass them as function references with &+/2.if outside of it.