PONYλM2Modula-2

TypeScript.CodeCompared.To/Elixir

An interactive executable cheatsheet comparing TypeScript and Elixir

TypeScript 6.0.3 Elixir 1.17
Hello World & Running
Hello, World
One line each, and both files execute top to bottom. IO.puts adds the newline; IO.write is the one that does not, and IO.inspect prints a debugging representation and returns its argument — which makes it droppable into the middle of a pipeline.
console.log("Hello, World!");
IO.puts("Hello, World!")
That last property is worth knowing on the first row, because it changes how you debug: value |> IO.inspect() |> next_step() prints without disturbing the data flow, where a console.log in the middle of a chain of array methods means restructuring the expression.
The build step goes away
Elixir compiles — to BEAM bytecode — but it does not type-check the way TypeScript does. A type error becomes a run-time failure with a good message rather than a build failure.
// tsc, or a bundler, or tsx — something compiles the types away // before anything runs, and a type error stops the build. const total: number = [3, 1, 4, 1, 5].reduce((sum, value) => sum + value, 0); // const wrong: string = total; // ← uncomment: it will not compile console.log("total:", total);
# There is no type checker in the way. The compiler checks syntax and # a few things it can prove, then the code runs. total = Enum.sum([3, 1, 4, 1, 5]) # wrong = "text" <> total # a run-time ArgumentError, not a build error IO.puts("total: #{total}")
What replaces the compiler is a combination worth understanding before deciding whether it is enough: pattern matching makes a wrong shape fail immediately and loudly at the boundary rather than propagating; Dialyzer does static analysis over typespecs and finds genuine type errors, more slowly and less completely than tsc; and supervision means a failure restarts a process rather than taking the system down. The honest summary is that you are trading compile-time proof for run-time resilience.
Everything Is Immutable
🚨 Nothing can be modified
🚨 const in TypeScript stops the binding being reassigned and does nothing about the object. In Elixir the value cannot be modified at all — there is no operation that would.
const first = [1, 2, 3]; const second = first; // a second NAME for one array second.push(4); console.log(first); // [1, 2, 3, 4] — const does not mean immutable const frozen = Object.freeze({ a: 1 }); // frozen.a = 2; // silently ignored, or throws in strict mode console.log(frozen);
first = [1, 2, 3] second = first # both names refer to the same value # There is no push. Every operation returns a NEW value. third = second ++ [4] IO.inspect(first) # [1, 2, 3] — untouched, always IO.inspect(third)
That single property removes a large class of bug that TypeScript spends effort defending against: no defensive copying, no Object.freeze, no readonly modifiers, no wondering whether a function you called kept a reference to your array. It also means the immutable-update patterns you already write with spread syntax are simply how the language works — %{map | key: value} is {...map, key: value}. The cost is allocation, which the BEAM is built around: values are shared structurally, and each process collects its own small heap independently.
Rebinding is not mutation
Elixir does let you rebind a name, which looks like mutation and is not: the old value is unchanged, and anything already holding it keeps holding it.
let count = 0; count = count + 1; // the same variable, a new value // A closure captures the VARIABLE, so this sees the update: const report = () => console.log("count is", count); count = 5; report(); // 5
count = 0 count = count + 1 # a NEW binding shadowing the old one # A closure captures the VALUE at the moment it is created, so this # still sees 1 rather than 5: report = fn -> IO.puts("count is #{count}") end count = 5 report.()
The closure is where the difference becomes visible, and it is the reverse of the JavaScript surprise: a TypeScript closure captures the variable and sees later assignments, while an Elixir closure captures the value and cannot. That removes the classic loop-variable bug entirely — there is no var-versus-let question, because a closure made in an iteration can only see that iteration's values. Note the call syntax: an anonymous function is invoked with .(), which is a genuine wart everyone comments on.
Pattern Matching
🚨 = is a match, not an assignment
🚨 = is the match operator. It asserts that the left side describes the right side, binds any variables in it, and raises when the shape is wrong.
const [first, second] = [1, 2]; const { x, y } = { x: 10, y: 20 }; console.log(first, second, x, y); // Destructuring a shape that does not match gives undefined: const { missing } = { x: 1 } as any; console.log(missing); // undefined — no error
[first, second] = [1, 2] %{x: x, y: y} = %{x: 10, y: 20} IO.puts("#{first} #{second} #{x} #{y}") # A shape that does not match RAISES, immediately: result = try do %{missing: _} = %{x: 1} rescue MatchError -> "no match" end IO.puts(result)
That is the difference from destructuring, and it is the source of much of Elixir's reliability. TypeScript destructuring of a missing key gives undefined, which travels onward and fails somewhere else; an Elixir match fails at the line where the wrong shape arrived, with a message naming what it got. The habit to build is to match aggressively at function boundaries rather than checking defensively inside them — {:ok, value} = call() is a one-line assertion that the call succeeded.
Multiple function heads replace the if chain
Several function heads with the same name and arity, chosen by matching the argument. A discriminated union's switch becomes one clause per case, and the clause header is the type narrowing.
type Shape = | { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number }; function area(shape: Shape): number { switch (shape.kind) { case "circle": return 3.14159 * shape.radius * shape.radius; case "rect": return shape.width * shape.height; } } console.log(area({ kind: "rect", width: 2, height: 3 }));
defmodule Geometry do # One function, several heads. The right one is chosen by matching # the argument, and they are tried in order. def area(%{kind: :circle, radius: radius}) do 3.14159 * radius * radius end def area(%{kind: :rect, width: width, height: height}) do width * height end end IO.puts(Geometry.area(%{kind: :rect, width: 2, height: 3}))
Two differences from the TypeScript. There is no exhaustiveness checking — adding a third shape and forgetting a clause produces a FunctionClauseError when that shape arrives, rather than a build error, so the failure is loud but late. And the tag here is :circle, an atom — an interned constant that needs no declaration, which is what a string literal type is doing in the TypeScript. Guards (when radius > 0) add conditions to a head.
case, with and the happy path
with is the construct with no TypeScript equivalent and it is the one to learn early: each <- is a match, and the first that fails jumps to else with the unmatched value.
function parseAge(text: string): { ok: true; value: number } | { ok: false } { const value = Number(text); return Number.isInteger(value) ? { ok: true, value } : { ok: false }; } const result = parseAge("42"); if (result.ok && result.value >= 18) { console.log("adult:", result.value); } else { console.log("rejected"); }
parse_age = fn text -> case Integer.parse(text) do {value, ""} -> {:ok, value} _ -> :error end end # The with construct chains matches and falls out of the whole chain # on the first one that does not match — the happy path, no nesting. message = with {:ok, age} <- parse_age.("42"), true <- age >= 18 do "adult: #{age}" else _ -> "rejected" end IO.puts(message)
That is what replaces a chain of if (result.ok) checks or a stack of nested awaits with error branches: the happy path reads straight down the page and every failure exits through one place. Compare it to optional chaining and early returns in TypeScript — with does the same job but works on any shape rather than only on null. The {value, ""} match is asserting that Integer.parse consumed the whole string, which is a nice illustration of matching as validation.
Functions, Pipes & Arity
The pipe operator
|> passes the value on its left as the first argument of the call on its right, so Enum.filter(values, function) is written values |> Enum.filter(function).
const values = [1, 2, 3, 4, 5, 6]; const total = values .filter((value) => value % 2 === 0) .map((value) => value * value) .reduce((sum, value) => sum + value, 0); console.log(total);
values = [1, 2, 3, 4, 5, 6] total = values |> Enum.filter(fn value -> rem(value, 2) == 0 end) |> Enum.map(fn value -> value * value end) |> Enum.sum() IO.puts(total)
This reads like method chaining and is more general: because it is an operator rather than methods on a type, any function whose first parameter is the data can join a pipeline — including your own, which is why Elixir APIs are written data-first by convention. There is no reduce ceremony for common cases (Enum.sum, Enum.count, Enum.frequencies), and Stream is the lazy version of every Enum function for when the collection is large or infinite.
A function is its name AND its arity
Elixir identifies a function by name and arity, so describe/1 and describe/2 are different functions — which is why a function reference is written &String.upcase/1 with the number.
// One function, an optional parameter, and a body that checks. function describe(name: string, count: number = 1): string { return `${count} x ${name}`; } console.log(describe("widget")); console.log(describe("widget", 3)); // Passing a function by reference needs no arity: const shout = (text: string) => text.toUpperCase(); console.log(["a", "b"].map(shout));
defmodule Describe do # A default argument generates a SECOND function of lower arity. def describe(name, count \\ 1) do "#{count} x #{name}" end end IO.puts(Describe.describe("widget")) IO.puts(Describe.describe("widget", 3)) # Referring to a function requires its arity, because describe/1 and # describe/2 are genuinely different functions: shout = &String.upcase/1 IO.inspect(Enum.map(["a", "b"], shout))
The \\ default-argument syntax generates the lower-arity function for you, so it is sugar over the same rule rather than an exception to it. Two consequences: there is no overloading by type, only by arity and by pattern; and stack traces and documentation always name the arity, which is more precise than a TypeScript stack trace and takes a day to stop reading as noise.
Types Become Optional
Typespecs are documentation Dialyzer reads
@type and @spec look like TypeScript annotations and behave quite differently: the compiler records them and does not check them, and a separate tool — Dialyzer — analyzes them later.
interface Person { name: string; age: number; } function greet(person: Person): string { return `hello, ${person.name}`; } // greet({ name: "ada" }); // ← would not compile: age is missing console.log(greet({ name: "ada", age: 36 }));
defmodule Greeting do @type person :: %{name: String.t(), age: integer()} @spec greet(person()) :: String.t() def greet(person) do "hello, #{person.name}" end end # Nothing checks this at run time. Dialyzer, run separately, would. IO.puts(Greeting.greet(%{name: "ada", age: 36}))
The honest comparison. Dialyzer is success typing rather than type checking: it reports only what it can prove will fail, so it has no false positives and misses things tsc would catch. It is also slow enough that it runs in continuous integration rather than in your editor. What compensates is that pattern matching does much of the same work dynamically — a function head that expects %{name: name} rejects anything else at the door — so the failure is early and specific even without a checker. Write typespecs anyway; they are the best documentation the language has.
Maps, Structs & Keyword Lists
Objects become maps, and keys become atoms
A map with atom keys is the everyday shape, and %{map | key: value} is the spread-update — though it requires the key to exist already, where Map.put does not.
const counts: Record<string, number> = { apple: 2, pear: 5 }; const updated = { ...counts, plum: 1 }; console.log(updated.apple); console.log(updated["fig"]); // undefined console.log(Object.keys(updated).sort());
counts = %{apple: 2, pear: 5} updated = Map.put(counts, :plum, 1) IO.puts(updated.apple) # atom key, dot access IO.inspect(Map.get(updated, :fig)) # nil, not undefined IO.inspect(Enum.sort(Map.keys(updated)))
Two things a TypeScript developer should know early. Atoms are not garbage collected, so never create one from user input (String.to_atom(user_data) is a memory leak; String.to_existing_atom is the safe form). And there are two access syntaxes with different behavior: map.key raises when the key is missing, while map[:key] returns nil — which is the difference between a loud failure and a quiet one, and worth choosing deliberately.
Structs are maps with a known shape
A struct is a map with a fixed set of keys and its module name recorded inside it — so unlike a TypeScript interface, it exists at run time and can be pattern matched.
interface Person { name: string; age: number; } const ada: Person = { name: "Ada", age: 36 }; const older: Person = { ...ada, age: 37 }; console.log(older.name, older.age); // The interface exists only at compile time; at run time this is // an ordinary object with no marker of what it is.
defmodule Person do defstruct name: "", age: 0 end ada = %Person{name: "Ada", age: 36} older = %{ada | age: 37} IO.puts("#{older.name} #{older.age}") # The struct is a map carrying its module name, so the type IS # available at run time — and can be matched on. IO.inspect(older.__struct__)
That run-time presence is the practical difference. def handle(%Person{} = person) is a function head that accepts only a Person, which is nominal typing enforced dynamically; TypeScript's structural interface disappears at compile time and cannot be checked later. What structs do not give you: no methods (functions live in the module and take the struct as the first argument), no inheritance, and no way to add a key that was not declared — a typo in a struct literal is a compile error, which is one of the few places Elixir checks more than it looks like it does.
Tagged Tuples vs Exceptions
Failure is a value, by convention
The convention is a tagged tuple: {:ok, value} or {:error, reason}, returned as an ordinary value and matched by the caller. Exceptions exist and are reserved for genuinely exceptional things.
function parseCount(text: string): number { const value = Number(text); if (!Number.isInteger(value)) throw new Error(`not a number: ${text}`); return value; } try { console.log(parseCount("42")); console.log(parseCount("oops")); } catch (error) { console.log("caught:", (error as Error).message); }
parse_count = fn text -> case Integer.parse(text) do {value, ""} -> {:ok, value} _ -> {:error, "not a number: " <> text} end end report = fn text -> case parse_count.(text) do {:ok, value} -> IO.puts(value) {:error, message} -> IO.puts("caught: " <> message) end end report.("42") report.("oops")
The pairing to learn is the bang suffix: File.read/1 returns a tagged tuple and File.read!/1 raises, so the API tells you which you are getting. Coming from TypeScript, the mental shift is that a failure the caller can reasonably handle is a return value, and a failure they cannot is a crash that supervision deals with — which is the next section. There is no Result type enforcing this; it is a convention the whole ecosystem follows, and with is what makes chaining it pleasant.
Processes Instead of async
🚨 A process is not a Promise
🚨 The unit of concurrency is a process: its own heap, its own garbage collection, pre-emptively scheduled across all cores, and communicating only by copying messages. It is not a thread and it is not a Promise.
// One thread, one heap, one event loop. await yields to it, and a // long synchronous loop freezes everything. async function work(name: string, milliseconds: number): Promise<string> { await new Promise((resolve) => setTimeout(resolve, milliseconds)); return `${name} done`; } const results = await Promise.all([work("first", 20), work("second", 10)]); for (const result of results) console.log(result);
# A process has its OWN heap, is scheduled pre-emptively, and # communicates only by messages. Nothing is shared. parent = self() spawn(fn -> send(parent, {:done, "first done"}) end) spawn(fn -> send(parent, {:done, "second done"}) end) results = for _ <- 1..2 do receive do {:done, message} -> message end end Enum.each(Enum.sort(results), &IO.puts/1)
Three assumptions stop holding at once. There is no shared memory, so there is no locking and no data race — a message is a copy. Scheduling is pre-emptive, so a tight loop in one process cannot starve the others, which is the failure mode a blocking loop causes in Node. And processes are cheap: a few hundred bytes each, so hundreds of thousands are ordinary, and the answer to "how do I model this concurrent thing" is usually "one process per thing" rather than one shared structure. Task.async is the ergonomic wrapper you would normally use for this row.
State lives in a process, not a variable
With no mutable variables, state becomes a process that holds a value and updates it by receiving messages. Agent is the small ready-made version of exactly that loop.
// Shared mutable state, held in a closure. Safe here only because // there is one thread. function makeCounter() { let count = 0; return { increment: () => ++count, read: () => count, }; } const counter = makeCounter(); counter.increment(); counter.increment(); console.log(counter.read());
# Nothing is mutable, so state is a process that holds a value and # answers messages. Agent is the ready-made version. {:ok, counter} = Agent.start_link(fn -> 0 end) Agent.update(counter, fn count -> count + 1 end) Agent.update(counter, fn count -> count + 1 end) IO.puts(Agent.get(counter, fn count -> count end))
What this buys is that the state has an owner and a mailbox, so concurrent access is serialized by construction — the process handles one message at a time — rather than by a lock you remembered to take. GenServer is the general form and is what real code uses: it adds named registration, handle_call for request-and-reply, handle_cast for fire-and-forget, and the callbacks a supervisor needs. Coming from a closure-based store, the adjustment is that reading state is now a message round trip, so you batch rather than reading in a loop.
Let It Crash
🚨 An unhandled error kills one process
🚨 This is the sentence the whole platform is built around. A process that fails dies alone, its heap is reclaimed, and the rest of the system does not notice — which is what makes "let it crash" a strategy rather than negligence.
// In Node, an unhandled rejection or a thrown error in the wrong // place takes the PROCESS down — every request in flight with it. // So defensive try/catch is everywhere: try { JSON.parse("{ not json"); } catch (error) { console.log("caught, because we had to"); } // And a Node server registers the last line of defense, a blunt one: // process.on("unhandledRejection", (reason) => { /* log it, and hope */ }); console.log("still running");
# A crash kills ONE process. Nothing else notices unless it asked to. parent = self() spawn(fn -> Process.monitor(parent) raise "boom" end) # Give the doomed process a moment, then carry on. Process.sleep(50) IO.puts("still running")
So defensive error handling stops being the default. Rather than wrapping everything in try, you let a process fail on bad input and let a supervisor restart it in a known-good state — which handles the error you did not anticipate as well as the ones you did. The comparison to Node is stark: there, an unhandled rejection can take down a process serving thousands of requests, so try/catch is mandatory everywhere and a single missed one is an outage.
A supervisor is a restart strategy
A supervisor is an ordinary process whose job is starting children and restarting them when they die. :one_for_one restarts just the failed child; :one_for_all restarts all of them, for children that only make sense together.
// The Node equivalents are all outside the language: // // pm2 / systemd / Kubernetes restart the whole process // a cluster module worker restart one worker // a try/catch and a retry hand-written, per call site // // None of them can restart one piece of state and leave the rest // running, because there is no smaller unit than the process. console.log("supervision lives in the deployment");
# A supervisor starts children and restarts them when they die, # under a strategy you choose. children = [ %{id: :counter, start: {Agent, :start_link, [fn -> 0 end]}} ] {:ok, _supervisor} = Supervisor.start_link(children, strategy: :one_for_one) IO.puts("supervision is part of the program")
The thing with no TypeScript counterpart is the granularity. Node's restart unit is the operating-system process, so a supervisor is Kubernetes or systemd and restarting means dropping every connection. Here a supervision tree restarts one piece of state — one user session, one connection, one job — while everything around it keeps running. That is most of what people mean when they say the BEAM is built for reliability, and it is why Application in a Phoenix project is a supervision tree rather than a list of side effects.
Why You Are Actually Here
LiveView: the server renders, the socket updates
This is the destination most readers of this page are evaluating. The state is a process on the server, one per connected user; a click sends an event over a websocket, and what comes back is a diff of the rendered HTML.
// The React shape you have now: state on the client, an API in the // middle, and a serialization boundary you maintain by hand. // // const [count, setCount] = useState(0); // // useEffect(() => { // fetch("/api/count").then(r => r.json()).then(d => setCount(d.count)); // }, []); // // <button onClick={() => setCount(c => c + 1)}>{count}</button> // // Two languages, two type definitions of the same shape, a client // bundle, and a cache to invalidate. console.log("state here, data there, JSON in between");
# LiveView: the state lives in a PROCESS on the server, one per # connected user. An event arrives over a websocket, the process # updates its state, and the server sends back the DIFF. # # def mount(_params, _session, socket) do # {:ok, assign(socket, count: 0)} # end # # def handle_event("increment", _params, socket) do # {:noreply, update(socket, :count, &(&1 + 1))} # end # # def render(assigns) do # ~H""" # <button phx-click="increment">{@count}</button> # """ # end IO.puts("state and rendering on one side of the wire")
What disappears is the boundary: no API to design, no client-side store, no serialization types defined twice, no cache invalidation, and no separate front-end build for most of an application. What you take on is equally real — every user holds an open websocket and a server process, so the server's memory scales with concurrent users (cheaply, but not freely), latency is now in every interaction, and anything genuinely local (a drag, a canvas, an offline mode) still needs JavaScript, which LiveView supports through hooks. The honest recommendation is that it is excellent for applications whose state is genuinely server-side and a poor fit for ones that are not.
What transfers, and what does not
The last row, and the one worth being plain about: a TypeScript developer arrives with more transferable habits than they expect, and gives up one thing they will miss every day.
// Habits that carry across unchanged: // - immutable updates (spread syntax IS %{map | key: value}) // - array methods (map/filter/reduce become Enum.*) // - destructuring (it becomes pattern matching, and gets stronger) // - discriminated unions (they become tagged tuples and atoms) // - async thinking (though the mechanism is entirely different) const values = [1, 2, 3]; const doubled = values.map((value) => value * 2); console.log(doubled);
# And what does not: # - the type checker. Dialyzer is not tsc, and your editor will # not tell you a shape is wrong as you type it # - classes, inheritance and methods. Modules hold functions, and # the data is passed in # - mutable state anywhere at all # - the npm ecosystem's size. Hex is smaller and much better # curated, which cuts both ways # - the front-end job market's shape, which is worth being clear # about before a career decision values = [1, 2, 3] doubled = Enum.map(values, fn value -> value * 2 end) IO.inspect(doubled)
The thing you will miss is the type checker — not Elixir's dynamism in the abstract, but specifically the editor telling you a shape is wrong as you type it, which Dialyzer does not do. Everything else transfers better than it looks: immutable updates, map/filter/reduce, destructuring and discriminated unions are all things you already do, and they are how Elixir is written rather than a style you have to argue for. Start with pattern matching and with, be patient with |>, and leave OTP until you have a real reason to reach for it.

Thank you — anything else?