intermediate languages Elixir 1.16 · Updated April 2026

Elixir for TypeScript Developers

Learn Elixir from a TypeScript perspective: pattern matching, immutability, processes, and functional programming.

· 15 min read · AI-reviewed
-->

Elixir for TypeScript Developers

Quick Overview

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

Getting Started

# 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.

Core Concepts

Pattern Matching — destructuring on steroids

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.

Immutability — no let or const, it’s always immutable

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 — function composition with elegance

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.

Types and Comparisons

TypeScriptElixirNotes
numberinteger / floatArbitrary-precision integers; floats are IEEE 754
stringstringUTF-8 encoded; "string" or 'charlists'
booleanbooleantrue and false (nothing is falsy except false and nil)
any / unknownany (dynamic)No static types; pattern matching acts as a guard
objectmap%{"key" => value} or %{key: value} (atom keys)
Array<T>listLinked list, [1, 2, 3]; immutable
Set<T>MapSetMapSet.new([1, 2, 3])
Promise<T>Task / SupervisorLightweight processes and message passing
ErrorException / tupleUse {: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 — the underrated type

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.

Concurrency — processes, not threads

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.

Tasks — async operations without the boilerplate

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)

Supervisors — crash recovery

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.

Error Handling

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.

Functional Programming Patterns

Enum and Stream

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)

Higher-order functions and closures

# 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

Guards — add constraints to patterns

# 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

Common Gotchas

  1. Falsy values: Only false and nil are falsy. 0, "", [] are truthy.
  2. String vs charlists: "string" is UTF-8 binary; 'string' is a list of codepoints. Most code uses strings.
  3. Operators are functions: + is really Kernel.+/2. You can pass them as function references with &+/2.
  4. Semicolons are optional: Elixir uses newlines as statement terminators.
  5. Variable scope: Variables are scoped to their function/block. You can’t use a variable declared inside if outside of it.

Next Steps