PONY λ M2 Modula-2

Elixir.CodeCompared.To/Pony

An interactive executable cheatsheet comparing Elixir and Pony

Elixir 1.17 Pony 0.67
Compiled, Typed, No iex
Hello, World
Every Pony program starts at actor Main, and the constructor new create(env: Env) is where execution begins. env is the program's only handle on the outside world — standard output, arguments, environment variables all hang off it — and it must be passed explicitly to anything that wants to print. Every Pony cell on this page is a complete program in exactly this shape.
IO.puts("Hello, World!")
actor Main new create(env: Env) => env.out.print("Hello, World!")
The very first line already tells you the two biggest things about Pony. The entry point is an actor, so the concurrency model is not a library you opt into — it is the shape of the program. And env being an explicit parameter rather than a global means there is no ambient IO module to reach for: capability-security runs all the way down, and a function that was never handed env is structurally incapable of printing.
There is no iex, and no .exs
Elixir gives you three ways to run code — iex, an .exs script, and a compiled release — and the first two feel like the language has no compile step at all. Pony has exactly one: ponyc produces a native binary, and nothing runs until it does. The comment in the Pony cell is a line that would not compile.
value = "7" try do IO.puts(value + 1) rescue ArithmeticError -> IO.puts("ArithmeticError — found at RUNTIME") end
actor Main new create(env: Env) => let text = "7" // env.out.print(text + 1) -> argument not a subtype of parameter let number = try text.i64()? else I64(0) end env.out.print((number + 1).string())
Losing iex is the adjustment that stings longest. There is no place to paste three lines and see what they do, no h Enum.reduce, no recompile(), and no way to poke at a running system. What you get back is that the whole class of error above stops being a thing that happens in production at 3am — and a binary with no runtime to install, where an Elixir release still ships the entire BEAM.
Arbitrary-precision integers → fixed width that wraps
Elixir integers are bignums: they grow until memory runs out and never overflow. Pony's are machine words with a declared width — I8, I32, I64, U64, USize, I128 — and the default arithmetic operators wrap silently on overflow rather than raising.
huge = 2 ** 100 IO.puts(huge) max_signed_64 = 9_223_372_036_854_775_807 IO.puts(max_signed_64 + 1)
actor Main new create(env: Env) => let biggest = I64.max_value() env.out.print("I64 max = " + biggest.string()) env.out.print("max + 1 wraps = " + (biggest + 1).string()) // 128 bits is as wide as it gets — there is no bignum type let wide: I128 = I128.max_value() env.out.print("I128 max = " + wide.string())
This is the single most likely way an Elixir developer's first real Pony program produces a wrong answer instead of an error, because nothing warns you. Anywhere you were quietly relying on bignums — a factorial, a hash, an accumulating counter, money in the smallest unit — needs a deliberate choice of width now. Pony does offer checked (addc), saturating (add_partial, which is partial and can error) and explicitly-unsafe variants, but plain + is the wrapping one.
Everything is an expression — in both
This one is pure comfort. Both languages made if, case/match and block bodies produce values rather than perform control flow as a statement, so both let you assign the result of a conditional directly. Pony's if needs then and closes with end, and the value of a block is its last expression, exactly as in Elixir.
temperature = 31 description = if temperature > 30 do "hot" else "mild" end IO.puts(description)
actor Main new create(env: Env) => let temperature: I64 = 31 let description = if temperature > 30 then "hot" else "mild" end env.out.print(description)
One rule Elixir does not have comes attached: because the if has a type, both branches must agree on one. Drop the else and the type becomes (String | None) — Pony supplies None for the missing branch rather than letting the expression be untyped. That is the whole reason None exists as a value, and it is why it shows up so often in the Types section.
Comments and documentation
Elixir puts documentation in module attributes that survive into the compiled beam file, where h and ExDoc read them back. Pony uses a docstring — a string literal as the first thing in a type or method body — and // for ordinary comments.
defmodule ElixirPonyDocumented do @moduledoc "Rounds a temperature reading." @doc "Rounds to the nearest whole degree." @spec round_degrees(float()) :: integer() def round_degrees(reading), do: round(reading) end IO.puts(ElixirPonyDocumented.round_degrees(21.6))
primitive Documented """ Rounds a temperature reading. """ fun round_degrees(reading: F64): I64 => """ Rounds to the nearest whole degree. """ reading.round().i64() actor Main new create(env: Env) => env.out.print(Documented.round_degrees(21.6).string())
Note what is missing from the Pony version: there is no @spec, because the signature is the spec and the compiler enforces it. Elixir's typespecs are optional annotations that only Dialyzer reads, and only sometimes; in Pony the same information is mandatory, checked, and used to generate the machine code.
Actors: The Familiar Half
A process → an actor
Pony's actor keyword declares a type whose instances are independent units of concurrency — the same thing spawn hands you in Elixir. Writing let worker = Worker calls its constructor and brings one into existence. Its fields are its state, and because only that actor can ever touch them, no lock is involved.
worker = spawn(fn -> receive do {:greet, name} -> IO.puts("Hello, " <> name) end end) send(worker, {:greet, "Ada"}) Process.sleep(50)
actor Worker be greet(env: Env, name: String) => env.out.print("Hello, " + name) actor Main new create(env: Env) => let worker = Worker worker.greet(env, "Ada")
The mental model transfers wholesale — this is the half of Pony you already know. Two differences worth noticing immediately: creating an actor is a typed operation, so worker is a Worker and not an opaque pid, and there is no Process.sleep at the end. The Pony program exits when every actor is quiescent, so it waits for the message you sent by construction rather than by you guessing at a delay.
GenServer.cast → be
A be (behavior) is an asynchronous method: calling it appends a message to the actor's queue and returns immediately, exactly like GenServer.cast/2. It cannot return a value — its return type is always None — and the compiler enforces that, which is why the actor below is handed env so it can print for itself.
defmodule ElixirPonyLogger do use GenServer def start_link, do: GenServer.start_link(__MODULE__, 0) def init(count), do: {:ok, count} def handle_cast({:log, message}, count) do IO.puts("[#{count}] " <> message) {:noreply, count + 1} end end {:ok, logger} = ElixirPonyLogger.start_link() GenServer.cast(logger, {:log, "started"}) GenServer.cast(logger, {:log, "ready"}) Process.sleep(50)
actor Logger var _count: I64 = 0 be log(env: Env, message: String) => env.out.print("[" + _count.string() + "] " + message) _count = _count + 1 actor Main new create(env: Env) => let logger = Logger logger.log(env, "started") logger.log(env, "ready")
Compare the amount of ceremony. The Elixir version needs a use GenServer, a start_link, an init that establishes the state, a handle_cast clause that pattern-matches a tag out of a tuple, and a {:noreply, new_state} return that threads the state forward by hand. Pony's equivalent is a field and a method: the dispatch on message name is the method name, and the state update is an assignment.
No receive, and no selective receive
Elixir's receive scans the mailbox for the first message matching any clause, leaving the rest queued — that is selective receive, and it is what makes the ad-hoc protocol below work. Pony has no receive at all. Messages are dispatched to behaviors by name in strict arrival order, and an actor cannot decline to handle one now and take it later.
send(self(), {:low, "later"}) send(self(), {:high, "now"}) receive do {:high, message} -> IO.puts("urgent: " <> message) end receive do {:low, message} -> IO.puts("routine: " <> message) end
actor Inbox let _deferred: Array[String] = Array[String] be low(message: String) => _deferred.push(message) // Pony cannot defer it FOR you be high(env: Env, message: String) => env.out.print("urgent: " + message) for pending in _deferred.values() do env.out.print("routine: " + pending) end _deferred.clear() actor Main new create(env: Env) => let inbox = Inbox inbox.low("later") inbox.high(env, "now")
This is the first thing you will genuinely miss. Selective receive is how Elixir expresses "wait for the reply I care about and ignore the noise", and every use of it has to be rebuilt in Pony as explicit state: a queue field, a mode flag, a buffer you drain later. The upside is that a Pony mailbox can never contain a message nobody will ever match, which is the classic BEAM memory leak — an unbounded mailbox slowly filling with terms no receive clause accepts.
Threaded state → mutable fields
A GenServer's state is a value you return from every callback, so changing it means building a new term and handing it back to the runtime. A Pony actor's state is ordinary mutable fields, assigned in place. This is safe for precisely the reason it would not be in Elixir: the compiler has proved no other actor holds a writable reference to them.
defmodule ElixirPonyAccount do use GenServer def start_link, do: GenServer.start_link(__MODULE__, %{balance: 0, moves: 0}) def init(state), do: {:ok, state} def handle_cast({:deposit, amount}, state) do {:noreply, %{state | balance: state.balance + amount, moves: state.moves + 1}} end def handle_call(:report, _from, state), do: {:reply, state, state} end {:ok, account} = ElixirPonyAccount.start_link() GenServer.cast(account, {:deposit, 40}) GenServer.cast(account, {:deposit, 2}) IO.inspect(GenServer.call(account, :report))
actor Account var _balance: I64 = 0 var _moves: I64 = 0 be deposit(amount: I64) => _balance = _balance + amount _moves = _moves + 1 be report(env: Env) => env.out.print("balance=" + _balance.string() + " moves=" + _moves.string()) actor Main new create(env: Env) => let account = Account account.deposit(40) account.deposit(2) account.report(env)
Coming from Elixir this feels illegal for about a day. Mutable state has always been the thing you were protecting against, and the discipline of threading it through return values is how you knew nobody else could touch it. Pony gets the same guarantee from the type system instead of from immutability, which means it can offer you plain assignment without giving up a single safety property.
Message ordering: Pony guarantees more than the BEAM
Elixir guarantees message order only between a specific pair of processes: if A sends to C twice, those two arrive in order, but a message A sent to B, which B then forwarded to C, has no ordering relationship with A's own later message to C. Pony guarantees causal ordering — if sending m1 could possibly have caused m2, then m1 arrives first, transitively.
collector = spawn(fn -> receive do first -> receive do second -> IO.puts("got #{first} then #{second}") end end end) relay = spawn(fn -> receive do message -> send(collector, message) end end) send(relay, :through_the_relay) send(collector, :direct) Process.sleep(80)
actor Collector var _seen: String = "" be note(env: Env, label: String) => _seen = if _seen == "" then label else _seen + " then " + label end if _seen.contains("then") then env.out.print("got " + _seen) end actor Relay be forward(env: Env, collector: Collector, label: String) => collector.note(env, label) actor Main new create(env: Env) => let collector = Collector let relay = Relay relay.forward(env, collector, "through_the_relay") collector.note(env, "direct")
The Elixir program prints one of two orderings depending on scheduling; the Pony program is deterministic, because the send to relay causally precedes the send to collector. This is a strictly stronger guarantee than the BEAM offers and it removes a family of races you may not have realized you were exposed to — though in practice most Elixir code never notices, because supervision and explicit replies paper over it.
Millions of processes → millions of actors
Both runtimes make their unit of concurrency far cheaper than an OS thread — a few hundred bytes, scheduled cooperatively across a small pool of real threads — so "spawn one per item" is a reasonable design in both. The loop below creates a thousand of them in each language — a number chosen for the in-browser AtomVM, which is a microcontroller VM and times out on the ten thousand the full BEAM handles instantly.
parent = self() for index <- 1..1_000 do spawn(fn -> send(parent, {:done, index}) end) end total = Enum.reduce(1..1_000, 0, fn _, running -> receive do {:done, index} -> running + index end end) IO.puts("sum of ids = #{total}")
actor Tally var _total: I64 = 0 var _seen: I64 = 0 be add(env: Env, value: I64) => _total = _total + value _seen = _seen + 1 if _seen == 1000 then env.out.print("sum of ids = " + _total.string()) end actor Reporter be report(tally: Tally, env: Env, index: I64) => tally.add(env, index) actor Main new create(env: Env) => let tally = Tally var index: I64 = 1 while index <= 1000 do Reporter.report(tally, env, index) index = index + 1 end
The cost profile is close enough that a design that works on the BEAM usually works in Pony. The one thing that differs is death: an Elixir process ends when its function returns, and you can kill it from outside with Process.exit/2. A Pony actor has no exit and cannot be killed — it is garbage-collected when it becomes unreachable and its queue is empty, which means the runtime, not you, decides when it stops existing.
Reference Capabilities: The New Idea
Why Elixir copies, and why Pony does not have to
Every message you send in Elixir is deep-copied into the receiving process's heap. That is what makes per-process GC and crash isolation work, and it is why sending a large binary or a big map has a real cost. Pony sends a pointer. It is allowed to, because the compiler has already proved that the sender cannot still write to what it sent — that proof is the reference capability system, and it is the one genuinely new idea on this page.
payload = Enum.to_list(1..20_000) worker = spawn(fn -> receive do list -> IO.puts("worker received #{length(list)} items (a full copy)") end end) send(worker, payload) Process.sleep(200) IO.puts("sender still owns its own copy: #{length(payload)} items")
use "collections" actor Worker be consume_payload(env: Env, payload: Array[I64] val) => env.out.print("worker received " + payload.size().string() + " items (a pointer, not a copy)") actor Main new create(env: Env) => let payload = recover val let building = Array[I64](20000) for index in Range[I64](1, 20001) do building.push(index) end building end Worker.consume_payload(env, payload) env.out.print("sender shares the SAME array: " + payload.size().string() + " items")
Hold on to this row; the rest of the section is the machinery that makes it legal. The Elixir version has copied 20,000 integers into a second heap and both processes now own an independent list. The Pony version passed one machine word, and both actors are looking at the same memory — which is safe only because val means "globally immutable, forever", so neither of them can write to it.
val — the capability you already think in
Start with the one that needs no adjustment. A val reference is deeply, permanently immutable and freely shareable between actors — which is exactly what every term in Elixir already is. String literals are val, numbers are effectively val, and anything you build and freeze becomes val. If you write Pony as though every reference were val, you are writing Elixir with types.
settings = %{retries: 3, verbose: true} reader = spawn(fn -> receive do config -> IO.puts("retries = #{config.retries}") end end) send(reader, settings) send(reader, settings) Process.sleep(50) IO.puts("still readable here: #{settings.retries}")
class val Settings let retries: I64 let verbose: Bool new val create(retries': I64, verbose': Bool) => retries = retries' verbose = verbose' actor Reader be read(env: Env, settings: Settings val) => env.out.print("retries = " + settings.retries.string()) actor Main new create(env: Env) => let settings: Settings val = Settings(3, true) let reader = Reader reader.read(env, settings) reader.read(env, settings) env.out.print("still readable here: " + settings.retries.string())
Notice that val can be sent to any number of actors any number of times, with no ceremony and no copy — because immutability makes aliasing harmless, which is the same reasoning that lets the BEAM share large binaries off-heap instead of copying them. val is where you should start and where most of your data should stay; the other capabilities exist for the cases where you genuinely need to mutate.
iso and consume — mutable, and yours alone
An iso reference is isolated: the compiler guarantees it is the only usable reference to that object anywhere in the program. That makes it both mutable and sendable at the same time, which is a combination Elixir simply cannot express. The price is that handing it away requires consume, which destroys the name you consumed — mention it afterwards and the program will not compile.
buffer = ["first"] updated = buffer ++ ["second"] IO.inspect(buffer) IO.inspect(updated) IO.puts("the original is untouched — that is the only way Elixir offers")
actor Sink be receive_buffer(env: Env, buffer: Array[String] iso) => let owned = consume buffer owned.push("added by the receiver") // still mutable, now owned here env.out.print("sink holds " + owned.size().string() + " entries") actor Main new create(env: Env) => let buffer = recover iso Array[String] end buffer.push("first") buffer.push("second") Sink.receive_buffer(env, consume buffer) // env.out.print(buffer.size().string()) -> can't use a consumed local env.out.print("the sender gave up all access, so no copy was needed")
This is the row that pays for the whole system. In Elixir the only way to hand a mutable structure to someone else is to not have one — you build a new immutable version and send a copy. Pony lets you build it mutably, hand over ownership with zero copying, and keep mutating it on the other side, and it proves at compile time that you did not keep a back door open. consume is the syntax for "I am giving this away".
ref and box — mutable and readable, but never sent
ref is an ordinary mutable reference and box is a read-only view of one. Neither is sendable: they may be aliased freely, so the compiler cannot prove another alias will not mutate the object while another actor reads it. They are what you use inside a single actor, where there is no concurrency to protect against.
defmodule ElixirPonyTally do def total(numbers), do: Enum.reduce(numbers, 0, &+/2) end running = [1, 2, 3] running = running ++ [4] IO.puts(ElixirPonyTally.total(running))
primitive Tally // box: "I will read it, I promise not to write it" fun total(numbers: Array[I64] box): I64 => var running: I64 = 0 for number in numbers.values() do running = running + number end running actor Main new create(env: Env) => let running: Array[I64] ref = Array[I64] // ref: mutable, actor-local running.push(1); running.push(2); running.push(3) running.push(4) env.out.print(Tally.total(running).string())
The everyday rhythm of Pony is ref for working data inside an actor, box for parameters you only read, and val or iso at the boundary where something crosses to another actor. That last clause is the part with no Elixir analogue at all: in Elixir every boundary crossing is a copy, so there is nothing to distinguish and no annotation to write.
tag — the capability a pid already has
A tag reference carries no read and no write permission: you may compare it for identity and you may send messages to it, and that is all. Every actor reference in Pony is a tag, which is why one actor can never read another's fields. This is the capability you have been using all along without a name for it — a pid is precisely a tag.
worker = spawn(fn -> Process.sleep(50) end) IO.puts("is a pid? #{is_pid(worker)}") IO.puts("same pid? #{worker == worker}") IO.puts("alive? #{Process.alive?(worker)}") send(worker, :anything) IO.puts("you can send to it, but you cannot read its state")
actor Worker var _secret: I64 = 42 be reveal(env: Env) => env.out.print("only the actor itself can read _secret: " + _secret.string()) actor Main new create(env: Env) => let first: Worker tag = Worker let second: Worker tag = Worker env.out.print("same actor? " + (first is first).string()) env.out.print("same actor? " + (first is second).string()) // env.out.print(first._secret.string()) -> tag has no read permission first.reveal(env)
Seeing the pid described as a capability reframes the whole system: Elixir gives you exactly one of the six, applies it to exactly one kind of thing (processes), and hardcodes it. Pony generalizes that idea to every reference, so "what am I allowed to do with this, and who else can do it at the same time" becomes a property the compiler tracks for ordinary data too — not just for processes.
recover — building mutably, then freezing
Building a structure is naturally mutable; sharing it is naturally immutable. A recover block bridges the two: inside it you have ordinary mutable access, and the value that comes out is lifted to iso or val. The compiler allows this because inside the block it can see that nothing mutable from outside leaked in.
report = Enum.map_join(1..5, ", ", fn number -> "item-#{number}" end) IO.puts(report) IO.puts("binaries are built then shared — Elixir has no other mode")
use "collections" actor Publisher be publish(env: Env, report: String val) => env.out.print(report) actor Main new create(env: Env) => let report: String val = recover val let building = String // a mutable String ref in here var first = true for number in Range[I64](1, 6) do if not first then building.append(", ") end building.append("item-" + number.string()) first = false end building // lifted to val on the way out end Publisher.publish(env, report)
This pattern — mutate locally, freeze, then share — is the Pony answer to almost every place Elixir would have used Enum.reduce into a new immutable term. You get the efficiency of in-place building without giving up the guarantee that what you eventually publish can never change under a reader, and the recover block is the exact point where the compiler signs off on the trade.
The three sendable capabilities
Only three of the six may cross an actor boundary, and the reasoning is short: iso because nobody else can see it, val because nobody can write it, tag because you can neither read nor write it. ref, box and trn stay put. This cell sends one of each of the three in a single program.
parent = self() sink = spawn(fn -> receive do {unique, shared, who} -> IO.puts("iso-like: #{inspect(unique)}") IO.puts("val-like: #{shared}") IO.puts("tag-like: #{is_pid(who)}") send(parent, :done) end end) send(sink, {[1, 2, 3], "shared text", self()}) receive do :done -> IO.puts("everything Elixir sends was copied; nothing was shared") end
actor Sink be accept(env: Env, unique: Array[I64] iso, shared: String val, who: Main tag) => let owned = consume unique env.out.print("iso: " + owned.size().string() + " items, mutable and mine alone") env.out.print("val: " + shared + " — readable by everyone, writable by nobody") env.out.print("tag: " + (who is who).string() + " — sendable-to only") actor Main new create(env: Env) => let unique = recover iso Array[I64] end unique.push(1); unique.push(2); unique.push(3) Sink.accept(env, consume unique, "shared text", this)
The remaining capability, trn (transition), is the rarest: a writable reference that hands out read-only box aliases, so you can build something up while others watch, then freeze it to val. Most programs never need it. If you learn only three, learn val, iso and ref — they cover the overwhelming majority of real code.
Mutation Without a Lock
let and var
Pony distinguishes a binding that may be reassigned (var) from one that may not (let). This applies to locals and to fields alike. Elixir has no such distinction because it has no assignment — every = is a match that rebinds the name in a new scope.
total = 0 total = total + 5 total = total + 5 IO.puts(total) IO.puts("each line rebound the name; nothing was ever mutated")
actor Main new create(env: Env) => var total: I64 = 0 total = total + 5 total = total + 5 let limit: I64 = 100 // limit = 200 -> can't assign to a let local env.out.print(total.string() + " of " + limit.string())
The distinction that matters is not let versus var — it is that Pony genuinely overwrote the same memory while Elixir built a new value and pointed a new name at it. In a single-threaded body the difference is invisible, which is exactly why it is safe; the reference capability system is what stops it from being visible anywhere it would matter.
= is assignment, not a match
In Elixir = is the match operator: {:ok, value} = call() destructures and will raise if the shape is wrong, and ^existing pins a name so it compares rather than rebinds. In Pony = is plain assignment. There is no pin operator because there is nothing to pin, and destructuring lives entirely in match.
{:ok, port} = {:ok, 8080} IO.puts(port) expected = 8080 ^expected = port IO.puts("the pin compared instead of rebinding") try do {:ok, _} = {:error, :refused} rescue MatchError -> IO.puts("a failed match raises MatchError") end
actor Main new create(env: Env) => let outcome: (I64 | None) = I64(8080) match outcome | let port: I64 => env.out.print(port.string()) | None => env.out.print("no port") end let expected: I64 = 8080 let port2: I64 = 8080 env.out.print("compared explicitly: " + (expected == port2).string())
Losing assignment-as-assertion is a bigger adjustment than it sounds. A great deal of Elixir's error handling is really = failing loudly on an unexpected shape and a supervisor restarting the process. Pony has neither half of that: the shape is checked at compile time instead, so the runtime assertion is unnecessary, but there is also no supervisor waiting to catch what you did not anticipate.
Enum.reduce → a loop with an accumulator
Elixir has no mutable accumulator, so folding is the only way to carry a running value — Enum.reduce and recursion with an accumulator parameter are the same trick. Pony has while and for with a var, which is how the same job is written idiomatically there.
numbers = [4, 8, 15, 16, 23, 42] total = Enum.reduce(numbers, 0, fn number, running -> running + number end) largest = Enum.reduce(numbers, 0, fn number, best -> max(number, best) end) IO.puts("total = #{total}") IO.puts("largest = #{largest}")
actor Main new create(env: Env) => let numbers = [as I64: 4; 8; 15; 16; 23; 42] var total: I64 = 0 var largest: I64 = 0 for number in numbers.values() do total = total + number if number > largest then largest = number end end env.out.print("total = " + total.string()) env.out.print("largest = " + largest.string())
Two things to unlearn. There is no Enum module to reach for, so the loop is the idiom rather than a fallback you would be judged for — and a Pony for loop is an expression whose value is the last iteration's body, not a fold, so a running total must live in a var outside it. The Iter chain shown in the Collections section is the closest thing to Enum, but it is a library and not the default style.
Agent → just a field
When Elixir needs a mutable cell it wraps one in a process — that is all an Agent is, a GenServer whose only job is to hold a term and run functions against it. In Pony a mutable cell is a field, and the actor holding it is the same actor doing the work.
{:ok, cache} = Agent.start_link(fn -> %{} end) Agent.update(cache, fn state -> Map.put(state, :hits, 1) end) Agent.update(cache, fn state -> Map.update!(state, :hits, &(&1 + 1)) end) IO.inspect(Agent.get(cache, & &1))
use "collections" actor Cache let _counts: Map[String, I64] = Map[String, I64] be record(key: String) => _counts(key) = try _counts(key)? + 1 else 1 end be dump(env: Env) => for (key, count) in _counts.pairs() do env.out.print(key + " => " + count.string()) end actor Main new create(env: Env) => let cache = Cache cache.record("hits") cache.record("hits") cache.dump(env)
The Elixir version pays for its mutable cell with a process, a copy on every read, and a serialization point. Pony's costs nothing extra, because the isolation the process was providing is already provided by the type system. Whole categories of "wrap it in an Agent" plumbing simply evaporate — though so does the ability to inspect that state from a remote shell, which the OTP Gap section returns to.
The Type System
Optional typespecs → mandatory signatures
Elixir's @spec is documentation that Dialyzer may or may not check, and it is entirely optional. Pony requires a type on every parameter, every field and every non-obvious return, and checks all of them. Local variables are usually inferred from their initializer, though a bare numeric literal is not — 3.5 could be F32 or F64, so it needs an annotation too.
defmodule ElixirPonyGeometry do @spec area(number(), number()) :: number() def area(width, height), do: width * height end IO.puts(ElixirPonyGeometry.area(3, 4)) IO.puts(ElixirPonyGeometry.area(3.5, 2.0))
primitive Geometry fun area(width: F64, height: F64): F64 => width * height actor Main new create(env: Env) => let width: F64 = 3.5 // a bare literal has no inferable type env.out.print(Geometry.area(3, 4).string()) env.out.print(Geometry.area(width, 2.0).string())
The interesting loss is not the typing, it is the polymorphism you had for free. The Elixir function happily took integers and then floats because nothing checked; the Pony one is committed to F64, and making it work for both means generics over a numeric trait. Where Elixir's answer to "what types does this take" is "whatever works", Pony makes you decide up front — and then never lets a caller be wrong about it.
Any term → union types
Elixir variables hold anything, so a function returning either an integer or a string needs no annotation. Pony spells that as a union, (I64 | String), and the compiler then refuses to let you use the value until you have narrowed it with match. Unions are structural and anonymous — no wrapper type is created.
defmodule ElixirPonyLookup do def fetch(present?) do if present?, do: 42, else: "not found" end end for present? <- [true, false] do case ElixirPonyLookup.fetch(present?) do number when is_integer(number) -> IO.puts("number: #{number}") text -> IO.puts("text: #{text}") end end
primitive Lookup fun fetch(present: Bool): (I64 | String) => if present then I64(42) else "not found" end actor Main new create(env: Env) => for present in [true; false].values() do match Lookup.fetch(present) | let number: I64 => env.out.print("number: " + number.string()) | let text: String => env.out.print("text: " + text) end end
A union is the closest thing Pony has to Elixir's "a variable holds whatever", and the difference is that the set of possibilities is written down and the compiler checks you handled all of them. Drop the String branch from that match and the program will not build — where the Elixir version would have fallen through to a CaseClauseError the first time production handed it something unexpected.
nil → None inside a union
Elixir's nil is a value any variable may hold, which is why nil creeping into a pipeline is such a familiar bug. Pony has no null of any kind. None is an ordinary value of its own type, and a variable can only hold it if its declared type says so — (String | None). That makes "might be missing" visible in the signature.
settings = %{"host" => "example.com"} host = Map.get(settings, "host") port = Map.get(settings, "port") IO.inspect(host) IO.inspect(port) IO.puts(String.upcase(host)) try do IO.puts(String.upcase(port)) rescue FunctionClauseError -> IO.puts("nil reached a function that could not take it") end
use "collections" actor Main new create(env: Env) => let settings = Map[String, String] settings("host") = "example.com" let host: (String | None) = try settings("host")? else None end let port: (String | None) = try settings("port")? else None end // env.out.print(host.upper()) -> (String | None) has no member 'upper' match host | let found: String => env.out.print(found.upper()) | None => env.out.print("host is missing") end match port | let found: String => env.out.print(found.upper()) | None => env.out.print("port is missing") end
The commented-out line is the whole point: Pony will not let you call a String method on something that might be None, so the class of bug that produces FunctionClauseError at 3am simply cannot be written. The cost is that every optional value needs an explicit narrowing at the point of use, which is more typing than || and considerably more than an Elixir pipeline that quietly assumed the value was there.
Atoms and modules → primitive
A Pony primitive is a type with exactly one instance, so its name is both the type and the value — which makes it simultaneously Elixir's atom (a bare tag you can match on) and Elixir's module (a namespace for functions with no state). A union of primitives is how Pony spells an enumeration.
statuses = [:pending, :shipped, :delivered] for status <- statuses do label = case status do :pending -> "waiting to go out" :shipped -> "on its way" :delivered -> "arrived" end IO.puts("#{status}: #{label}") end
primitive Pending fun name(): String => "pending" primitive Shipped fun name(): String => "shipped" primitive Delivered fun name(): String => "delivered" type Status is (Pending | Shipped | Delivered) actor Main new create(env: Env) => let statuses = [as Status: Pending; Shipped; Delivered] for status in statuses.values() do let label = match status | Pending => "waiting to go out" | Shipped => "on its way" | Delivered => "arrived" end env.out.print(status.name() + ": " + label) end
Two things you gain over atoms. The match is exhaustive — add a fourth status and every match that forgot it stops compiling, where Elixir would have raised at runtime on the one code path nobody tested. And a primitive can carry methods, so the tag and the behavior that goes with it live together instead of being a bare atom plus a case in some helper module.
defstruct → class
A Pony class holds fields and methods and, unlike a struct, is genuinely mutable through a ref. Fields declared let are set once in a constructor; var fields can be reassigned. Constructors are named — new create(...) is the conventional default, and Person("Ada", 36) calls it.
defmodule ElixirPonyPerson do defstruct name: "", age: 0 def birthday(person), do: %{person | age: person.age + 1} end person = %ElixirPonyPerson{name: "Ada", age: 36} older = ElixirPonyPerson.birthday(person) IO.inspect(person) IO.inspect(older)
class Person let name: String var age: I64 new create(name': String, age': I64) => name = name' age = age' fun ref birthday() => age = age + 1 fun describe(): String => name + " is " + age.string() actor Main new create(env: Env) => let person = Person("Ada", 36) env.out.print(person.describe()) person.birthday() env.out.print(person.describe())
The trailing apostrophe in name' is a real identifier character in Pony, used by convention to name a constructor parameter after the field it initializes — there is no shadowing rule to work around, it is just a naming habit. The deeper difference is that birthday mutated the object rather than returning a new one, which is only possible because fun ref declares that it needs write access to the receiver.
Tuples
Both languages have tuples as an anonymous fixed-size product type, and both destructure them positionally. Pony writes the type as (String, I64), indexes with ._1, ._2, and destructures in a match or with a parenthesized left-hand side.
coordinate = {"origin", 0, 0} {label, x, y} = coordinate IO.puts("#{label} at #{x},#{y}") IO.puts(elem(coordinate, 0))
actor Main new create(env: Env) => let coordinate: (String, I64, I64) = ("origin", 0, 0) (let label, let x, let y) = coordinate env.out.print(label + " at " + x.string() + "," + y.string()) env.out.print(coordinate._1)
The habit to break is {:ok, value}. Elixir's tagged tuple is the universal return convention precisely because there is no type system to express "one thing or another" — Pony has unions for that, and using a tuple where a union belongs gives up the exhaustiveness checking that makes the union worth having. Tuples in Pony are for genuinely fixed-shape groupings, like the coordinate above.
Pattern Matching
case → match
Pony's match is structurally Elixir's case: one subject, a list of alternatives introduced by |, first match wins, the whole thing is an expression. The syntax differences are that the arrow is =>, a binding pattern must name its type (let n: I64), and there is an else clause instead of a bare _.
for code <- [200, 404, 500, 302] do message = case code do 200 -> "OK" 404 -> "Not Found" 500 -> "Server Error" _ -> "Unhandled: #{code}" end IO.puts("#{code} #{message}") end
actor Main new create(env: Env) => for code in [as I64: 200; 404; 500; 302].values() do let message = match code | 200 => "OK" | 404 => "Not Found" | 500 => "Server Error" else "Unhandled: " + code.string() end env.out.print(code.string() + " " + message) end
One rule catches everyone: a literal pattern in Pony matches with ==, so it only works for types that define equality, and a bare identifier is always a binding — never a comparison against an existing variable. Elixir needs ^ to say "compare, do not rebind"; Pony has no pin because a comparison against an existing value belongs in a guard, which the next rows cover.
Matching on type — something Elixir cannot do
This is the pattern with no Elixir counterpart. Because Pony knows the static type of everything, a match arm can dispatch on which member of a union the value actually is, and the bound name is narrowed to that type inside the arm — so calling String methods on it becomes legal.
values = [42, "hello", 3.5, :ok] for value <- values do described = cond do is_integer(value) -> "integer doubled: #{value * 2}" is_binary(value) -> "string upcased: #{String.upcase(value)}" is_float(value) -> "float halved: #{value / 2}" true -> "something else: #{inspect(value)}" end IO.puts(described) end
primitive Ok type Value is (I64 | String | F64 | Ok) actor Main new create(env: Env) => let values = [as Value: I64(42); "hello"; F64(3.5); Ok] for value in values.values() do let described = match value | let number: I64 => "integer doubled: " + (number * 2).string() | let text: String => "string upcased: " + text.upper() | let fraction: F64 => "float halved: " + (fraction / 2).string() | Ok => "something else: ok" end env.out.print(described) end
The Elixir version has to use cond with guard functions because case cannot match on type, and every branch is still working with an untyped value — String.upcase(value) is only safe because you checked. Pony's arms genuinely change the type of the binding, so the compiler is the one guaranteeing that text.upper() is legal, and it will tell you if you add a member to the union and forget an arm.
when → if inside a match arm
Elixir guards are restricted to a whitelist of guard-safe functions, which is why writing one that needs a helper is so awkward. Pony spells a guard if after the pattern and allows any expression that returns Bool, including calls to your own functions.
defmodule ElixirPonyGrades do def grade(score) when score >= 90, do: "A" def grade(score) when score >= 80, do: "B" def grade(score) when score >= 70, do: "C" def grade(_score), do: "F" end for score <- [95, 83, 71, 40] do IO.puts("#{score} -> #{ElixirPonyGrades.grade(score)}") end
primitive Grades fun passing(score: I64): Bool => score >= 70 fun grade(score: I64): String => match score | let value: I64 if value >= 90 => "A" | let value: I64 if value >= 80 => "B" | let value: I64 if passing(value) => "C" else "F" end actor Main new create(env: Env) => for score in [as I64: 95; 83; 71; 40].values() do env.out.print(score.string() + " -> " + Grades.grade(score)) end
Note the third arm calling passing, an ordinary function of your own — Elixir would reject that outright, since only guard-safe BIFs and macros defined with defguard are allowed in a when. That restriction exists because BEAM guards must be provably side-effect-free and fast; Pony evaluates the guard as normal code, so it has no such rule.
No map or binary destructuring in patterns
A large fraction of everyday Elixir matching is destructuring maps (%{status: status}) and binaries (<<head, rest::binary>>) directly in a pattern. Pony has neither. A Map is an ordinary library type, so you look values up and handle the failure; a String is sliced with methods.
response = %{status: 200, body: "hello", headers: %{}} %{status: status, body: body} = response IO.puts("#{status}: #{body}") <<first_byte, rest::binary>> = "pony" IO.puts("#{first_byte} then #{rest}")
use "collections" actor Main new create(env: Env) => let response = Map[String, String] response("status") = "200" response("body") = "hello" try env.out.print(response("status")? + ": " + response("body")?) else env.out.print("a key was missing") end let word = "pony" try env.out.print(word(0)?.string() + " then " + word.substring(1)) end
This is the most keystroke-visible loss on the page. Elixir's map patterns are partial by design — they assert only the keys you name and ignore the rest — and Pony cannot express that, because a Map is a hash table with runtime keys, not a structural type. Where the shape is genuinely known, the right Pony answer is a class with named fields, which is checked at compile time rather than asserted at runtime.
Destructuring tuples in a match
Tuples are the one shape Pony does destructure in a pattern, and the syntax lines up closely with Elixir's. Each element may be a literal to compare against or a typed binding, and the arms are checked for exhaustiveness against the tuple's type.
readings = [{:celsius, 21}, {:fahrenheit, 70}, {:celsius, -5}] for reading <- readings do case reading do {:celsius, degrees} when degrees < 0 -> IO.puts("freezing: #{degrees}C") {:celsius, degrees} -> IO.puts("#{degrees}C") {:fahrenheit, degrees} -> IO.puts("#{degrees}F") end end
primitive Celsius primitive Fahrenheit type Scale is (Celsius | Fahrenheit) actor Main new create(env: Env) => let readings = [as (Scale, I64): (Celsius, 21); (Fahrenheit, 70); (Celsius, -5)] for reading in readings.values() do match reading | (Celsius, let degrees: I64) if degrees < 0 => env.out.print("freezing: " + degrees.string() + "C") | (Celsius, let degrees: I64) => env.out.print(degrees.string() + "C") | (Fahrenheit, let degrees: I64) => env.out.print(degrees.string() + "F") end end
Reading that Pony match should feel almost like reading the Elixir one, which is a fair summary of the whole section: the shapes you match on are narrower, but the mechanism is the same one you already use dozens of times a day. What is new is that leaving out the Fahrenheit arm is a compile error rather than a lurking CaseClauseError.
error, ? and No Payload
Partial functions and the ? operator
A Pony function that can fail is declared partial by putting ? after its return type, and every call to it must be written with a ? too — so failure is visible at both ends. A partial call is only legal inside a try block, which is the enforcement mechanism: you cannot ignore the possibility.
defmodule ElixirPonyDivide do def divide(numerator, denominator) do if denominator == 0 do raise ArithmeticError, "division by zero" else div(numerator, denominator) end end end IO.puts(ElixirPonyDivide.divide(10, 2)) try do IO.puts(ElixirPonyDivide.divide(10, 0)) rescue ArithmeticError -> IO.puts("caught it") end
primitive Divide fun apply(numerator: I64, denominator: I64): I64 ? => if denominator == 0 then error end numerator / denominator actor Main new create(env: Env) => try env.out.print(Divide(10, 2)?.string()) env.out.print(Divide(10, 0)?.string()) env.out.print("never reached") else env.out.print("caught it") end
The ? at the call site is the part with no Elixir equivalent and it is quietly excellent: reading a function body, every place that can jump out is marked. Elixir's ! naming convention (Map.fetch!) is trying to do this by discipline; Pony makes it syntax and has the compiler check it. Note also that the second call short-circuited the whole try — "never reached" is genuinely never reached.
error carries no reason at all
This is the hardest thing to accept. Elixir's raise carries an exception struct with a message and fields, and {:error, reason} carries whatever you want. Pony's error is a single, uniform, argument-less signal — there is exactly one of it in the whole language, and no way to attach information.
defmodule ElixirPonyConfigLoader do def load(source) do cond do source == "" -> {:error, :empty_source} String.length(source) < 3 -> {:error, {:too_short, String.length(source)}} true -> {:ok, source} end end end for source <- ["", "ab", "production"] do IO.inspect(ElixirPonyConfigLoader.load(source)) end
primitive ConfigLoader fun load(source: String): String ? => if source == "" then error end if source.size() < 3 then error end // the same, featureless error source actor Main new create(env: Env) => for source in [""; "ab"; "production"].values() do let outcome = try "loaded " + ConfigLoader.load(source)? else "failed — and you cannot tell WHICH check failed" end env.out.print(outcome) end
Every Elixir habit built on {:error, reason} — matching on the reason, logging it, mapping it to an HTTP status — has to move somewhere else. The idiomatic Pony answer is the next row: return a union that includes your own error type, and keep error for cases where "it did not work" really is all the caller needs. Treat ? as control flow, not as error reporting.
{:ok, _} / {:error, _} → a union return
Where you want a reason to travel with the failure, Pony's answer is a union of the success type and an error type of your own — the type-system equivalent of the tagged tuple, checked for exhaustiveness at every call site. The classes below carry exactly the payload the Elixir version put in the tuple.
defmodule ElixirPonyPortParser do def parse(text) do case Integer.parse(text) do {number, remainder} when remainder == "" and number > 0 -> {:ok, number} {_number, remainder} when remainder != "" -> {:error, "trailing junk: " <> remainder} _ -> {:error, "not a number: " <> text} end end end for text <- ["8080", "80x", "http"] do case ElixirPonyPortParser.parse(text) do {:ok, port} -> IO.puts("port #{port}") {:error, reason} -> IO.puts("rejected — #{reason}") end end
class val ParseFailure let reason: String new val create(reason': String) => reason = reason' primitive PortParser fun parse(text: String): (I64 | ParseFailure) => try let number = text.i64()? if number > 0 then number else ParseFailure("not positive: " + text) end else ParseFailure("not a number: " + text) end actor Main new create(env: Env) => for text in ["8080"; "0"; "http"].values() do match PortParser.parse(text) | let port: I64 => env.out.print("port " + port.string()) | let failure: ParseFailure => env.out.print("rejected — " + failure.reason) end end
This is the shape to reach for by default, and it is better than the tagged tuple in one specific way: the compiler knows the complete list of things parse can return, so a caller that forgets the failure branch does not compile. In Elixir nothing stops you writing {:ok, port} = parse(text) and discovering the other case in production.
with → chained partial calls in one try
Elixir's with exists to chain operations that each return {:ok, _} and bail out on the first that does not. Pony gets the same short-circuit for free: several ? calls inside one try stop at the first failure and fall into the else.
defmodule ElixirPonyPipeline do def numeric(text) do case Integer.parse(text) do {number, remainder} when remainder == "" -> {:ok, number} _ -> :error end end end inputs = ["10", "4", "x"] result = with {:ok, first} <- ElixirPonyPipeline.numeric(Enum.at(inputs, 0)), {:ok, second} <- ElixirPonyPipeline.numeric(Enum.at(inputs, 1)), true <- second != 0 do {:ok, div(first, second)} else _ -> :error end IO.inspect(result)
actor Main new create(env: Env) => let inputs = ["10"; "4"; "x"] try let first = inputs(0)?.i64()? let second = inputs(1)?.i64()? if second == 0 then error end env.out.print("quotient = " + (first / second).string()) else env.out.print("the chain failed at the first bad step") end try let bad = inputs(2)?.i64()? // "x" — fails here env.out.print("never printed: " + bad.string()) else env.out.print("second chain failed as expected") end
The Pony version is shorter and needs no special construct, because ? is already a short-circuit. What it cannot do is Elixir's else clauses that match on which step failed and with what reason — there is only one featureless error, so distinguishing the failures means going back to the union return from the previous row.
There is no "let it crash" safety net
"Let it crash" is a bargain: you write the happy path, an unanticipated failure kills the process, and a supervisor restarts it from known-good state. Pony has the first half — an unhandled error in a behavior stops that behavior — and none of the second. Nothing restarts, nothing is notified, and the actor stays alive with whatever state it had.
defmodule ElixirPonyFragile do def run(divisor), do: div(100, divisor) end worker = spawn(fn -> ElixirPonyFragile.run(0) end) reference = Process.monitor(worker) receive do {:DOWN, ^reference, :process, _pid, reason} -> IO.puts("the process died: #{inspect(reason)}") IO.puts("in a real app a supervisor would now restart it from clean state") end
actor Fragile var _processed: I64 = 0 be run(env: Env, divisor: I64) => try if divisor == 0 then error end _processed = _processed + 1 env.out.print("ok, processed = " + _processed.string()) else // Without this 'else' the behavior aborts silently — nothing is notified, // nothing restarts, and the actor keeps its half-updated state. env.out.print("this behavior failed; the actor lives on unchanged") end actor Main new create(env: Env) => let fragile = Fragile fragile.run(env, 5) fragile.run(env, 0) fragile.run(env, 2)
This is the biggest cultural adjustment of the whole page. Elixir lets you be optimistic because OTP is underneath you; Pony expects the compiler to have eliminated most failures up front and leaves the rest entirely to you. In practice that means writing try ... else where you would have written nothing, and building recovery by hand — which the OTP Gap section takes up next.
What OTP Gives You And Pony Does Not
Supervisor → write it yourself
There is no Supervisor, no restart strategy, no child spec and no application tree in Pony. What a supervisor does — notice a failure, discard the broken state, start a replacement — has to be an actor you write, and the "notice" part is the hard bit, since a Pony actor cannot be monitored and does not announce its own trouble.
defmodule ElixirPonyFlaky do use GenServer def start_link(_), do: GenServer.start_link(__MODULE__, :ok, name: :elixir_pony_flaky) def init(:ok), do: {:ok, 0} def handle_cast(:boom, _state), do: raise("deliberate failure") def handle_call(:ping, _from, state), do: {:reply, {:alive, state}, state} end {:ok, supervisor} = Supervisor.start_link([ElixirPonyFlaky], strategy: :one_for_one) IO.inspect(GenServer.call(:elixir_pony_flaky, :ping)) GenServer.cast(:elixir_pony_flaky, :boom) Process.sleep(100) IO.inspect(GenServer.call(:elixir_pony_flaky, :ping)) IO.puts("same name, fresh process — the supervisor restarted it") Supervisor.stop(supervisor)
actor Flaky var _healthy: Bool = true be work(env: Env, supervisor: Supervisor, fail: Bool) => if fail then _healthy = false supervisor.report_failure(env) // you must report it yourself else env.out.print("worked; healthy = " + _healthy.string()) end actor Supervisor var _worker: (Flaky | None) = None be start(env: Env) => let replacement = Flaky _worker = replacement replacement.work(env, this, false) be report_failure(env: Env) => env.out.print("supervisor saw a failure — replacing the worker by hand") start(env) actor Main new create(env: Env) => let supervisor = Supervisor supervisor.start(env)
Read the Pony side carefully: the worker had to volunteer that it was in trouble. There is no Process.monitor, no {:DOWN, ...}, no link, and no way for an outside actor to observe that a behavior aborted. Losing OTP is by far the largest thing an Elixir developer gives up moving to Pony, and it is a fair trade only because the type system removed many of the failures the supervisor was there to absorb.
GenServer.call → a Promise
A behavior cannot return a value, so there is no direct equivalent of GenServer.call/2. The idiom is to pass a Promise in as an argument: the actor fulfills it when it has the answer, and the caller attaches a callback with next. Everything stays asynchronous — nothing blocks, and there is no timeout to configure.
defmodule ElixirPonyStock do use GenServer def start_link, do: GenServer.start_link(__MODULE__, 12) def init(count), do: {:ok, count} def handle_call({:reserve, quantity}, _from, count) when quantity <= count do {:reply, {:ok, count - quantity}, count - quantity} end def handle_call({:reserve, _quantity}, _from, count), do: {:reply, :out_of_stock, count} end {:ok, stock} = ElixirPonyStock.start_link() IO.inspect(GenServer.call(stock, {:reserve, 5})) IO.inspect(GenServer.call(stock, {:reserve, 99}))
use "promises" actor Stock var _count: I64 = 12 be reserve(quantity: I64, reply: Promise[String]) => if quantity <= _count then _count = _count - quantity reply("reserved; " + _count.string() + " left") else reply("out of stock") end actor Main new create(env: Env) => let stock = Stock let first = Promise[String] first.next[None]({(answer: String) => env.out.print(answer) }) stock.reserve(5, first) let second = Promise[String] second.next[None]({(answer: String) => env.out.print(answer) }) stock.reserve(99, second)
The mechanical cost is small; the stylistic cost is not. GenServer.call lets you write straight-line code that reads top to bottom, and a Promise turns every request into a callback, so a sequence of three dependent calls becomes three nested closures. Pony offers no await, so this really is the shape — which is a strong argument for designing actors that push results forward rather than being asked for them.
Registry and named processes → pass the reference
Elixir lets you name a process — an atom, {:via, Registry, ...}, :global — and then reach it from anywhere by that name. Pony has no process registry of any kind. An actor is reachable only by a reference someone handed you, so wiring is explicit and happens at construction.
defmodule ElixirPonyClock do use GenServer def start_link, do: GenServer.start_link(__MODULE__, :ok, name: :elixir_pony_clock) def init(:ok), do: {:ok, :ok} def handle_call(:now, _from, state), do: {:reply, "tick", state} end {:ok, _pid} = ElixirPonyClock.start_link() IO.inspect(Process.whereis(:elixir_pony_clock) != nil) IO.inspect(GenServer.call(:elixir_pony_clock, :now)) IO.puts("any module anywhere can reach it by name")
actor Clock be tick(env: Env) => env.out.print("tick") actor Scheduler let _clock: Clock new create(clock: Clock) => _clock = clock // the reference is handed in, not looked up be run(env: Env) => _clock.tick(env) actor Main new create(env: Env) => let clock = Clock let scheduler = Scheduler(clock) scheduler.run(env)
You lose the ability to reach anything from anywhere, which is genuinely convenient and genuinely a source of hidden coupling — a named process is a global variable wearing a hat. Explicit wiring makes the dependency graph visible in the constructors, at the cost of threading references through layers that did not previously need to know about them.
No distribution, no Node, no :global
Elixir's actors are location-transparent: Node.connect plus a {name, node} tuple and a send reaches another machine, and libraries like :pg and Horde build on that. Pony's actors are strictly in-process. Talking to another machine means writing a network protocol over TCP yourself, and the reference capability system stops at the process boundary.
IO.inspect(Node.self()) IO.puts("Node.connect/1, :global and :pg are part of the RUNTIME, not a library") IO.puts("send({name, :other@host}, message) would reach another machine") IO.puts("a pid means the same thing on every node in the cluster")
use "net" actor Main new create(env: Env) => env.out.print("Pony actors are in-process only") env.out.print("crossing a machine boundary means TCP and a protocol you write") env.out.print("the 'net' package gives you sockets, and nothing above them")
The reason is the same one that makes the whole system work: a reference capability is a compile-time proof about one address space, and there is no way to extend that proof across a network. Erlang bought distribution by copying every message, which is exactly the cost Pony declined to pay. If a clustered BEAM is central to your design, this is the row that should give you pause.
No hot code upgrade, and no running-system introspection
The BEAM keeps two versions of a module loaded and lets a running process switch between them, which is what makes hot upgrades and :observer possible. Pony compiles to a single native binary with no module table at runtime, so there is nothing to swap and nothing to inspect.
IO.inspect(:erlang.system_info(:process_count)) IO.inspect(Process.info(self(), :message_queue_len)) IO.inspect(:code.is_loaded(Enum) != false) IO.puts(":observer, :sys.get_state/1 and hot upgrades all rest on this")
actor Main new create(env: Env) => env.out.print("no module table, so no :code.is_loaded and no hot upgrade") env.out.print("no process registry, so no :observer process list") env.out.print("deploying a new version means restarting the binary")
Losing hot upgrade rarely matters — most Elixir shops deploy by restarting anyway — but losing introspection often does. There is no remote shell to attach, no :sys.get_state on a misbehaving actor, and no live mailbox lengths. Observability in Pony is whatever logging and metrics you built in advance, which is a real change in how you debug production.
Enum → Arrays and Iterators
List → Array
Elixir's list is a singly-linked cons list: cheap to prepend, O(n) to index, and immutable. Pony's Array is a contiguous growable buffer — O(1) indexed, mutable through a ref, and indexing is partial, so array(index)? needs a ? and a try.
numbers = [4, 8, 15] extended = numbers ++ [16] prepended = [1 | numbers] IO.inspect(extended) IO.inspect(prepended) IO.puts(Enum.at(numbers, 1)) IO.puts(length(numbers))
actor Main new create(env: Env) => let numbers = [as I64: 4; 8; 15] numbers.push(16) numbers.unshift(1) env.out.print(", ".join(numbers.values())) try env.out.print(numbers(2)?.string()) end env.out.print("size = " + numbers.size().string())
Two habits to retire. Indexing is cheap now, so the Elixir instinct to avoid Enum.at in a loop no longer applies — but it can fail, so it drags a try along with it. And push mutated the array in place rather than producing a new one, which means passing an array to a function that takes a ref is handing over write access, not a snapshot.
Map → collections.Map
Elixir's map is a built-in with literal syntax and structural pattern matching. Pony's Map is an ordinary library hash table from the collections package, so it needs a use line, explicit key and value types, and partial lookup with ?.
inventory = %{"apples" => 3, "pears" => 7} inventory = Map.put(inventory, "plums", 2) IO.inspect(Map.get(inventory, "pears")) IO.inspect(Map.get(inventory, "figs", 0)) IO.inspect(Map.has_key?(inventory, "apples")) for {fruit, count} <- Enum.sort(inventory) do IO.puts("#{fruit}: #{count}") end
use "collections" actor Main new create(env: Env) => let inventory = Map[String, I64] inventory("apples") = 3 inventory("pears") = 7 inventory("plums") = 2 try env.out.print("pears: " + inventory("pears")?.string()) end env.out.print("figs: " + (try inventory("figs")? else I64(0) end).string()) env.out.print("has apples? " + inventory.contains("apples").string()) for (fruit, count) in inventory.pairs() do env.out.print(fruit + ": " + count.string()) end
The unfamiliar part is that this Map is mutable and iteration order is unspecified — Elixir maps are immutable and small ones iterate in key order, which a surprising amount of code accidentally depends on. If you want the Elixir semantics exactly, the persistent map in the next row is the closer match.
Persistent collections — the Elixir semantics, opt-in
The collections/persistent package holds immutable structural-sharing versions of Map, List and Vec — the same data structures Elixir uses by default. Updating one returns a new collection and leaves the original untouched, so they are val and freely sendable between actors.
original = %{a: 1} derived = Map.put(original, :b, 2) IO.inspect(original) IO.inspect(derived) IO.puts("the original never changed — every Elixir map works this way")
use "collections/persistent" actor Main new create(env: Env) => let original = Map[String, I64] let with_a = original("a") = 1 let derived = with_a("b") = 2 env.out.print("original size = " + original.size().string()) env.out.print("with_a size = " + with_a.size().string()) env.out.print("derived size = " + derived.size().string())
Reach for these whenever a collection has to cross an actor boundary or be shared widely, since a persistent collection is naturally val and needs no recover. The trade is the one Elixir already made and you already know: structural sharing costs an allocation and a pointer chase per update, where the mutable Map writes in place.
Enum and Stream → Iter
The itertools package's Iter wraps any iterator and gives it the chainable operations you know from Enum and Stream. It is lazy like Stream, and collect is what forces it. Type arguments on map are explicit — map[I64] — because Pony has no return-type inference across a lambda.
numbers = 1..10 result = numbers |> Stream.filter(fn number -> rem(number, 2) == 0 end) |> Stream.map(fn number -> number * number end) |> Enum.take(3) IO.inspect(result)
use "itertools" use "collections" actor Main new create(env: Env) => let result = Iter[I64](Range[I64](1, 11)) .filter({(number: I64): Bool => (number % 2) == 0 }) .map[I64]({(number: I64): I64 => number * number }) .take(3) .collect(Array[I64]) env.out.print(", ".join(result.values()))
This is the closest Pony gets to the pipeline you write every day, and it is close enough to be comfortable — but note it is a library type wrapping an iterator, not a protocol every collection implements. There is no Enumerable, so a type you define does not automatically work with Iter unless you give it a values() method returning an Iterator.
MapSet → collections.Set
Both languages give you a hash set with the usual algebra. Pony's Set lives in collections, adds with set rather than put, and — like Map — is mutable in place rather than returning a new value.
first = MapSet.new([1, 2, 3]) second = MapSet.new([3, 4]) IO.inspect(MapSet.union(first, second) |> Enum.sort()) IO.inspect(MapSet.intersection(first, second) |> Enum.sort()) IO.inspect(MapSet.member?(first, 2))
use "collections" actor Main new create(env: Env) => let first = Set[I64] first.set(1); first.set(2); first.set(3) let second = Set[I64] second.set(3); second.set(4) env.out.print("union size = " + (first or second).size().string()) env.out.print("intersection size = " + (first and second).size().string()) env.out.print("contains 2? = " + first.contains(2).string())
Pony spells union and intersection as the operators or and and, which reads well once you expect it — these are ordinary method names (op_or, op_and) that the operator syntax desugars to, the same mechanism that makes + work on your own classes. Elixir has no operator overloading at all, so MapSet.union is the only spelling available there.
Comprehensions → for over an iterator
Elixir's for is a comprehension: it collects results, supports several generators, filters inline, and can change the collectable with into:. Pony's for is a plain loop whose value is its last iteration, so collecting means pushing into an array you made yourself.
pairs = for row <- 1..3, column <- 1..3, row != column do {row, column} end IO.inspect(pairs) IO.puts("collected #{length(pairs)} pairs")
use "collections" actor Main new create(env: Env) => let pairs = Array[(I64, I64)] for row in Range[I64](1, 4) do for column in Range[I64](1, 4) do if row != column then pairs.push((row, column)) end end end for pair in pairs.values() do env.out.write("(" + pair._1.string() + "," + pair._2.string() + ") ") end env.out.print("") env.out.print("collected " + pairs.size().string() + " pairs")
Comprehensions are one of the places Elixir is simply more expressive, and there is no Pony feature that closes the gap — the nested loop with an explicit accumulator is the idiomatic answer, not a workaround. What you do get in exchange is that Range is an ordinary iterator you can hand to Iter, so lazy pipelines and loops compose from the same pieces.
Strings and Binaries
No string interpolation
Elixir's #{} calls to_string on anything and splices it in. Pony has no interpolation syntax at all: you call .string() on each value and join the pieces with +, which is the single most visible difference in day-to-day code.
name = "Ada" year = 1843 score = 99.5 IO.puts("#{name} in #{year} scored #{score}") IO.puts("#{name} has #{String.length(name)} letters")
actor Main new create(env: Env) => let name = "Ada" let year: I64 = 1843 let score: F64 = 99.5 env.out.print(name + " in " + year.string() + " scored " + score.string()) env.out.print(name + " has " + name.size().string() + " letters")
It is more typing, and it is also the one place Pony feels genuinely dated next to Elixir. The compensation is that .string() comes from the Stringable interface, so any type you define gets to say how it renders and the compiler will not let you concatenate something that has not — where #{} will happily interpolate an inspect-ish fallback for a struct nobody wrote a String.Chars implementation for.
iodata → a mutable String, then freeze
Elixir avoids repeated concatenation by building iodata — a nested list of binaries the runtime flattens once at the end. Pony's answer is the mutable String ref you saw in the recover row: append in place, then promote the finished value to val.
parts = for number <- 1..5, do: ["item-", Integer.to_string(number), " "] report = IO.iodata_to_binary(parts) IO.puts(report) IO.puts("iodata avoids building intermediate binaries")
use "collections" actor Main new create(env: Env) => let report: String val = recover val let building = String for number in Range[I64](1, 6) do building.append("item-") building.append(number.string()) building.append(" ") end building end env.out.print(report)
Both languages are solving the same problem — naive concatenation in a loop is quadratic in both — but note where the safety comes from. Elixir's iodata is safe because everything is immutable and the flattening is a pure function; Pony's is safe because the recover block proves nothing mutable escaped, which is why the result can be handed to another actor without a copy.
Binaries → String and Array[U8]
An Elixir string is a UTF-8 binary, and byte_size versus String.length is the familiar bytes-versus-graphemes split. Pony's String is also UTF-8 bytes: size() counts bytes, runes() iterates codepoints, and array() hands you the raw Array[U8].
word = "héllo" IO.puts(byte_size(word)) IO.puts(String.length(word)) IO.inspect(:binary.first(word)) IO.inspect(String.to_charlist(word))
actor Main new create(env: Env) => let word = "héllo" env.out.print("bytes = " + word.size().string()) var codepoints: USize = 0 for rune in word.runes() do codepoints = codepoints + 1 end env.out.print("codepoints = " + codepoints.string()) try env.out.print("first byte = " + word(0)?.string()) end env.out.print("as bytes = " + word.array().size().string() + " entries")
Neither language gives you graphemes for free — Elixir's String.length counts codepoints too, and String.graphemes is the one that handles combining characters. Pony has no grapheme support in the standard library at all, so text that needs real Unicode segmentation is a place where Elixir's standard library is meaningfully ahead.
The String module → String methods
The everyday operations line up almost one for one; the difference is that Elixir calls module functions on a binary and Pony calls methods on the object. Note that split returns an Array[String] and that anything which can fail — i64(), indexing — is partial.
sentence = " the quick brown fox " IO.puts(String.trim(sentence)) IO.puts(String.upcase(sentence) |> String.trim()) IO.inspect(String.split(String.trim(sentence), " ")) IO.puts(String.contains?(sentence, "quick")) IO.puts(String.replace(sentence, "quick", "slow") |> String.trim())
actor Main new create(env: Env) => let sentence = " the quick brown fox " let trimmed = sentence.clone().>strip() env.out.print(trimmed.clone().string()) env.out.print(trimmed.upper()) env.out.print(", ".join(trimmed.split(" ").values())) env.out.print(sentence.contains("quick").string()) let replaced = trimmed.clone() replaced.replace("quick", "slow") env.out.print(replaced.string())
Two Pony details show up here. strip and replace mutate in place rather than returning a new string, so they need a ref — which is why clone() appears, since a literal is val. And .> is the "call this and return the receiver anyway" operator, the closest thing Pony has to chaining a mutating call, which the next section revisits when the pipe operator comes up.
Functions Without Clauses or Pipes
Multiple function clauses → one body with a match
Defining a function several times with different heads and letting the runtime pick is central to Elixir style. Pony has one body per function; the dispatch moves inside as a match. There is no overloading either — one name means one signature.
defmodule ElixirPonyArea do def area({:circle, radius}), do: 3.14159 * radius * radius def area({:square, side}), do: side * side def area({:rectangle, width, height}), do: width * height end IO.puts(ElixirPonyArea.area({:circle, 2.0})) IO.puts(ElixirPonyArea.area({:square, 3.0})) IO.puts(ElixirPonyArea.area({:rectangle, 3.0, 4.0}))
class val Circle let radius: F64 new val create(radius': F64) => radius = radius' class val Square let side: F64 new val create(side': F64) => side = side' class val Rect let width: F64 let height: F64 new val create(width': F64, height': F64) => width = width' height = height' primitive Area fun apply(shape: (Circle | Square | Rect)): F64 => match shape | let circle: Circle => F64(3.14159) * circle.radius * circle.radius | let square: Square => square.side * square.side | let rect: Rect => rect.width * rect.height end actor Main new create(env: Env) => env.out.print(Area(Circle(2.0)).string()) env.out.print(Area(Square(3.0)).string()) env.out.print(Area(Rect(3.0, 4.0)).string())
The Elixir version is undeniably lighter to write. What the Pony version buys is exhaustiveness: add a Triangle to the union and the compiler points at this match, where Elixir would raise FunctionClauseError the first time one reached this function in production. Also note fun apply — naming a method apply is what makes Area(...) callable like a function.
The pipe operator has no equivalent
Elixir's |> threads a value through a chain of functions as the first argument, and it shapes how the whole language is written. Pony has nothing like it. Method chaining works where each step returns an object, and .> chains calls that return nothing by yielding the receiver instead — but a chain of free functions has to be nested or given intermediate names.
result = " Hello, World " |> String.trim() |> String.downcase() |> String.split(", ") |> Enum.map(&String.capitalize/1) |> Enum.join(" | ") IO.puts(result)
use "itertools" actor Main new create(env: Env) => let cleaned = " Hello, World ".clone().>strip() let lowered = cleaned.lower() let capitalized = Iter[String](lowered.split_by(", ").values()) .map[String]({(part: String): String => recover val let built = String built.append(part.substring(0, 1).upper()) built.append(part.substring(1)) built end }) .collect(Array[String]) env.out.print(" | ".join(capitalized.values()))
Compare the two cells honestly: this is the row where Pony is at its least pleasant to read, and no idiom fixes it. Chaining only works when each step is a method on the value, so a standard library organized as free functions — which most of Pony's is not, fortunately — would be painful. Expect intermediate let bindings where you would have written a pipeline. One trap in passing: Pony's split takes a set of delimiter characters, not a delimiter string, so split(", ") would break on the comma and the space and leave an empty field between them — split_by is the one that takes the whole string, and it is what String.split/2 does in Elixir.
Anonymous functions → lambdas
Pony's lambda syntax is {(parameter: Type): ReturnType => body }. Types are mandatory on both ends, since there is no inference across a lambda boundary. A lambda captures by value, and the captured variables' capabilities have to allow it — which is why a lambda that escapes to another actor usually captures only val data.
double = fn number -> number * 2 end add = fn first, second -> first + second end IO.puts(double.(21)) IO.puts(add.(20, 22)) apply_twice = fn transform, value -> transform.(transform.(value)) end IO.puts(apply_twice.(double, 5))
primitive Apply fun twice(transform: {(I64): I64} val, value: I64): I64 => transform(transform(value)) actor Main new create(env: Env) => let double = {(number: I64): I64 => number * 2 } let add = {(first: I64, second: I64): I64 => first + second } env.out.print(double(21).string()) env.out.print(add(20, 22).string()) env.out.print(Apply.twice(double, 5).string())
Calling a lambda needs no dot — double(21), not Elixir's double.(21) — because a Pony lambda is an object with an apply method and calling it is ordinary method-call syntax. That also means the function type {(I64): I64} val is just an interface — capability and all, which is why the parameter has to say val — so anything you write with a matching apply can be passed where a lambda is expected.
Default and named arguments
Elixir writes a default with \\ and has no named arguments — the convention is a trailing keyword list, which is really just a list of two-element tuples. Pony has real defaults and real named arguments, passed with the where keyword at the call site.
defmodule ElixirPonyGreeter do def greet(name, greeting \\ "Hello", punctuation \\ "!") do "#{greeting}, #{name}#{punctuation}" end end IO.puts(ElixirPonyGreeter.greet("Ada")) IO.puts(ElixirPonyGreeter.greet("Ada", "Welcome")) IO.puts(ElixirPonyGreeter.greet("Ada", "Hi", "?"))
primitive Greeter fun greet(name: String, greeting: String = "Hello", punctuation: String = "!"): String => greeting + ", " + name + punctuation actor Main new create(env: Env) => env.out.print(Greeter.greet("Ada")) env.out.print(Greeter.greet("Ada", "Welcome")) env.out.print(Greeter.greet("Ada" where punctuation = "?"))
The third call is the one Elixir cannot express: skipping a middle argument and naming a later one. Elixir's workaround is the trailing keyword list, which pushes the checking to runtime — a typo in an option key is silently ignored — where Pony's named arguments are checked by the compiler like any other parameter.
Receiver capabilities on functions
Between fun and the method name sits the capability the method needs on this: fun box reads, fun ref writes, fun val requires an immutable receiver, and a bare fun means box. Elixir has no equivalent because there is no receiver and nothing is ever mutated.
defmodule ElixirPonyMeter do defstruct readings: [] def record(meter, value), do: %{meter | readings: [value | meter.readings]} def average(%{readings: []}), do: 0.0 def average(meter), do: Enum.sum(meter.readings) / length(meter.readings) end meter = %ElixirPonyMeter{} meter = ElixirPonyMeter.record(meter, 10) meter = ElixirPonyMeter.record(meter, 20) IO.puts(ElixirPonyMeter.average(meter))
class Meter let _readings: Array[I64] = Array[I64] fun ref record(value: I64) => // needs write access to this _readings.push(value) fun box average(): F64 => // read-only access is enough if _readings.size() == 0 then return 0.0 end var total: I64 = 0 for reading in _readings.values() do total = total + reading end total.f64() / _readings.size().f64() actor Main new create(env: Env) => let meter = Meter meter.record(10) meter.record(20) env.out.print(meter.average().string())
That annotation is what lets the compiler prove the sharing rules hold — a box reference will not let you call record at all, so a function that promised only to read genuinely cannot write. The Elixir version achieves the same guarantee by making mutation impossible, at the cost of rebuilding and rebinding the struct on every single update.
Contracts: Traits and Interfaces
@behaviour → trait
A Pony trait is nominal: a type participates only if it says is TraitName, exactly like @behaviour plus @impl. Unlike its Elixir counterpart, a trait can carry default method bodies, so shared implementation and the contract live in one place.
defmodule ElixirPonyNamed do @callback name() :: String.t() defmacro __using__(_options) do quote do @behaviour ElixirPonyNamed def greet, do: "Hello, " <> name() <> "!" end end end defmodule ElixirPonyDog do use ElixirPonyNamed @impl true def name, do: "Rex" end IO.puts(ElixirPonyDog.greet())
trait Named fun name(): String // required — no body fun greet(): String => "Hello, " + name() + "!" // default implementation class Dog is Named fun name(): String => "Rex" class Cat is Named fun name(): String => "Whiskers" fun greet(): String => name() + " ignores you" // overridden actor Main new create(env: Env) => env.out.print(Dog.greet()) env.out.print(Cat.greet())
Look at what the Elixir version needed to get default implementations: a __using__ macro that injects code into the caller. That is the standard workaround and it is why so much Elixir library code is macro-heavy — a @behaviour alone cannot carry an implementation. Pony gets the same result with a plain method body and no metaprogramming at all.
Protocols → structural interfaces
An interface is Pony's structural contract: any type with matching method signatures satisfies it automatically, with no declaration on either side. That is closer to Elixir's protocols than a trait is — except that the match is checked at compile time rather than dispatched at runtime.
defmodule ElixirPonyPrinter do def describe(value), do: "as text: " <> to_string(value) end IO.puts(ElixirPonyPrinter.describe(42)) IO.puts(ElixirPonyPrinter.describe("already text")) IO.puts(ElixirPonyPrinter.describe(3.5)) IO.puts("String.Chars dispatches on the value's type at runtime")
interface val Measurable fun length_of(): I64 // no 'is Measurable' needed anywhere class val Rope let meters: I64 new val create(meters': I64) => meters = meters' fun length_of(): I64 => meters class val Cable new val create() => None fun length_of(): I64 => 12 primitive Report fun describe(item: Measurable): String => "length " + item.length_of().string() actor Main new create(env: Env) => env.out.print(Report.describe(Rope(30))) env.out.print(Report.describe(Cable))
Neither Rope nor Cable mentions Measurable, which is the point — structural typing lets you retrofit an interface onto types you did not write, the same freedom defimpl gives you for a foreign struct. The difference is that Pony resolves it statically, so there is no dispatch table and no Protocol.UndefinedError waiting at runtime.
defimpl for a type you do not own
Elixir lets you implement a protocol for someone else's struct, or for a built-in, from anywhere in your project. Pony cannot: a trait must be declared on the type itself, so retrofitting one onto a library type is impossible — you either use a structural interface or wrap the type.
defprotocol ElixirPonySizeOf do def size_of(value) end defimpl ElixirPonySizeOf, for: BitString do def size_of(value), do: byte_size(value) end defimpl ElixirPonySizeOf, for: List do def size_of(value), do: length(value) end IO.puts(ElixirPonySizeOf.size_of("hello")) IO.puts(ElixirPonySizeOf.size_of([1, 2, 3]))
// A trait cannot be added to Array from outside, so wrap instead. trait Sized fun size_of(): USize class val SizedArray is Sized let items: Array[I64] val new val create(items': Array[I64] val) => items = items' fun size_of(): USize => items.size() class val SizedText is Sized let text: String new val create(text': String) => text = text' fun size_of(): USize => text.size() actor Main new create(env: Env) => let numbers = recover val [as I64: 1; 2; 3] end env.out.print(SizedText("hello").size_of().string()) env.out.print(SizedArray(numbers).size_of().string())
This is a real ergonomic loss and the reason is deliberate: Pony has no equivalent of Elixir's protocol consolidation step, and allowing out-of-module trait implementations would break the compiler's ability to reason about a type from its definition alone. Structural interfaces absorb most of the pain — they need no declaration at all — but only when the foreign type already happens to have the right method. The Elixir cell is display-only: a user-defined defprotocol with two defimpls does not finish inside the in-browser AtomVM's 45-second compile budget.
Generics — no Elixir equivalent exists
Elixir needs no generics because nothing is typed: a function that works on "a list of anything" is just a function on a list. Pony has real parametric polymorphism, written with square brackets, and the constraint after the colon says what the type parameter must satisfy — here Stringable val, so the box can render whatever it holds.
defmodule ElixirPonyBox do defstruct [:item] def wrap(item), do: %__MODULE__{item: item} def describe(box), do: "holding: #{box.item}" end IO.puts(ElixirPonyBox.wrap(42) |> ElixirPonyBox.describe()) IO.puts(ElixirPonyBox.wrap("text") |> ElixirPonyBox.describe()) IO.puts("nothing checked that these were compatible")
class val Box[A: Stringable val] let item: A new val create(item': A) => item = item' fun describe(): String => "holding: " + item.string() actor Main new create(env: Env) => let number_box = Box[I64](42) let text_box = Box[String]("text") env.out.print(number_box.describe()) env.out.print(text_box.describe()) // Box[Env](env) -> Env does not satisfy Stringable val
Generics are the feature with no Elixir counterpart whatsoever, and the useful part is the constraint rather than the parameter. A: Stringable val is a contract the compiler enforces at every instantiation, so Box cannot be built around something it could not render — which is precisely the class of mistake the Elixir version discovers only when describe is finally called.
Runtime protocol dispatch → static resolution
Elixir's protocol dispatch is a runtime lookup on the value's type, which is why consolidate_protocols exists as a build step. Pony resolves every call at compile time, so a trait call is a direct call or a vtable entry, never a search. The row below is anchor_norun because a user-defined defprotocol is far too slow for the in-browser AtomVM to compile.
defprotocol ElixirPonyRenderable do def render(value) end defimpl ElixirPonyRenderable, for: Integer do def render(value), do: "int(#{value})" end defimpl ElixirPonyRenderable, for: BitString do def render(value), do: "str(#{value})" end IO.puts(ElixirPonyRenderable.render(42)) IO.puts(ElixirPonyRenderable.render("hi")) IO.puts("each call looked the implementation up at runtime")
trait val Renderable fun render(): String class val RenderableNumber is Renderable let value: I64 new val create(value': I64) => value = value' fun render(): String => "int(" + value.string() + ")" class val RenderableText is Renderable let value: String new val create(value': String) => value = value' fun render(): String => "str(" + value + ")" actor Main new create(env: Env) => let items = [as Renderable: RenderableNumber(42); RenderableText("hi")] for item in items.values() do env.out.print(item.render()) end
The trade is speed for reach. Pony's dispatch costs nothing at runtime and cannot fail, but the set of implementations is fixed when the binary is built; Elixir's can be extended by any library that loads later, which is what makes Jason.Encoder and Inspect work for types their authors never saw. Neither answer is better — they are different points on the same trade-off.
Runtime: GC, Scheduling, Backpressure
Per-process GC → per-actor GC
Here the two runtimes agree almost exactly. Each Elixir process and each Pony actor owns a private heap collected independently, so a collection pauses one unit of concurrency rather than the world. That is why both languages hold up under latency requirements that defeat a single-heap garbage collector.
parent = self() spawn(fn -> Enum.each(1..5_000, fn index -> _ = Integer.to_string(index) end) send(parent, :finished) end) receive do :finished -> IO.puts("that process allocated and collected 5,000 terms in its OWN heap") IO.puts("no other process paused while it did — there is no global collection") end
use "collections" actor Churner be churn(env: Env) => var kept: USize = 0 for index in Range[I64](1, 5000) do let temporary = index.string() // garbage, collected in THIS actor's heap kept = kept + temporary.size() end env.out.print("allocated and collected privately; bytes seen = " + kept.string()) actor Main new create(env: Env) => Churner.churn(env)
Pony goes one step further than the BEAM: because reference capabilities tell the collector exactly which actors can reach an object, there is no tracing pause across actors and no separate binary heap with its own reference counting to reason about. In practice both give you the same headline property — no global stop-the-world pause — and it is one of the few places where moving to Pony asks you to change nothing at all.
Reduction counting → no preemption at all
The BEAM preempts: every process gets a budget of reductions and is descheduled when it runs out, which is why one tight loop cannot starve the rest of the system. Pony does not preempt. A behavior runs to completion on its scheduler thread, so a long computation holds that thread for its whole duration.
hog = spawn(fn -> Enum.reduce(1..200_000, 0, fn index, sum -> sum + index end) end) polite = spawn(fn -> IO.puts("the polite process still ran promptly") end) Process.sleep(150) IO.puts("hog still running? #{Process.alive?(hog)}") IO.puts("polite finished long ago — the scheduler preempted the hog") Process.exit(hog, :kill) IO.puts("alive after kill? #{Process.alive?(polite)}")
actor Hog be grind(env: Env) => var total: I64 = 0 var index: I64 = 0 while index < 200000 do total = total + index index = index + 1 end env.out.print("hog finished: " + total.string()) actor Polite be speak(env: Env) => env.out.print("the polite actor runs on a DIFFERENT scheduler thread") actor Main new create(env: Env) => Hog.grind(env) Polite.speak(env)
On a multicore machine this usually looks fine, because the other actors are on other threads — the danger appears when you have more busy actors than cores, or one behavior that blocks. Two BEAM reflexes stop working: a runaway loop cannot be killed from outside (there is no Process.exit), and long work must be chunked into several behaviors by hand so the actor yields between them.
Unbounded mailboxes → built-in backpressure
An Elixir mailbox grows without limit, so a producer faster than its consumer eventually exhausts memory — which is why GenStage and Broadway exist. Pony builds backpressure into the runtime: when an actor's queue grows past a threshold it becomes overloaded, and the runtime mutes actors that keep sending to it until it catches up.
slow = spawn(fn -> Process.sleep(30) IO.puts("consumer drained #{:erlang.process_info(self(), :message_queue_len) |> elem(1)} messages") end) Enum.each(1..5_000, fn index -> send(slow, {:work, index}) end) IO.puts("the producer never blocked — nothing applied backpressure") Process.sleep(80)
actor Consumer var _handled: I64 = 0 be work(env: Env, index: I64) => _handled = _handled + 1 if _handled == 5000 then env.out.print("consumer drained " + _handled.string() + " messages") end actor Producer be flood(env: Env, consumer: Consumer) => var index: I64 = 0 while index < 5000 do consumer.work(env, index) // the runtime may MUTE this actor mid-loop index = index + 1 end env.out.print("producer finished; the runtime throttled it if needed") actor Main new create(env: Env) => Producer.flood(env, Consumer)
This is a genuine advantage over the BEAM and it needs no library. Muting is transparent — the producer's code is unchanged and it simply gets scheduled less — so the "unbounded mailbox eats the node" failure mode is handled by the runtime rather than by you adopting GenStage. It also means a Pony actor cannot easily be used as a deliberate buffer, since the runtime will slow the producer down whether you wanted that or not.
Process exit → actors are garbage-collected
An Elixir process ends when its function returns, or when someone kills it, and a leaked process lives forever. A Pony actor has no exit: it is collected when it becomes unreachable and its message queue is empty. There is no Process.exit, no :normal versus :shutdown, and no way to stop one from outside.
finite = spawn(fn -> IO.puts("I do one thing and return") end) Process.sleep(20) IO.puts("finite alive? #{Process.alive?(finite)}") lingering = spawn(fn -> Process.sleep(:infinity) end) IO.puts("lingering alive? #{Process.alive?(lingering)}") Process.exit(lingering, :kill) Process.sleep(20) IO.puts("after kill? #{Process.alive?(lingering)}")
actor Ephemeral be work(env: Env) => env.out.print("I do one thing; once nobody holds me, I am collected") actor Main new create(env: Env) => Ephemeral.work(env) // no reference kept — collectable after this runs let held = Ephemeral held.work(env) // 'held' keeps it alive for the rest of create env.out.print("there is no Process.exit — you cannot kill an actor")
The consequence for design is that "stop this worker" is not something you do — you drop your reference and let it drain. A stuck actor holding a reference to itself, or reachable from a long-lived registry-like actor you built, will simply never be collected, and unlike the BEAM there is no supervisor timeout and no :observer process list to notice it with.
No Macros At All
defmacro → nothing
Elixir gives you the AST as data, quote/unquote to build it, and defmacro to run your code at compile time. Pony has no macro system, no compile-time evaluation, and no way to generate code. What you write is what compiles.
defmodule ElixirPonyAssertions do defmacro assert_equal(left, right) do quote do if unquote(left) == unquote(right) do IO.puts("ok: #{unquote(Macro.to_string(left))}") else IO.puts("FAILED: #{unquote(Macro.to_string(left))}") end end end end require ElixirPonyAssertions import ElixirPonyAssertions assert_equal(1 + 1, 2) assert_equal(2 + 2, 5)
primitive Assertions fun assert_equal(env: Env, label: String, left: I64, right: I64) => if left == right then env.out.print("ok: " + label) else env.out.print("FAILED: " + label) end actor Main new create(env: Env) => // The label has to be passed by hand — nothing can see the source text. Assertions.assert_equal(env, "1 + 1", 1 + 1, 2) Assertions.assert_equal(env, "2 + 2", 2 + 2, 5)
Every macro you have written turns into either a function, a bit of repetition, or a code generator you run before ponyc. Notice specifically what the function cannot do: recover the source text of its own arguments. Anything that depends on seeing the expression rather than its value — assertion messages, ExUnit's failure output, query DSLs — has no Pony equivalent at all.
use and __using__ → explicit composition
use SomeModule runs __using__/1 at compile time and injects whatever code it wants into your module — the mechanism behind use GenServer, use Ecto.Schema and most of Phoenix. Pony has no injection point. Shared behavior comes from a trait with default methods, and shared state from composition.
defmodule ElixirPonyTimestamped do defmacro __using__(_options) do quote do def stamp(message), do: "[#{inspect(__MODULE__)}] " <> message defoverridable stamp: 1 end end end defmodule ElixirPonyReport do use ElixirPonyTimestamped end IO.puts(ElixirPonyReport.stamp("generated"))
trait Timestamped fun source(): String fun stamp(message: String): String => "[" + source() + "] " + message class Report is Timestamped fun source(): String => "Report" class Audit is Timestamped fun source(): String => "Audit" fun stamp(message: String): String => "<" + source() + "> " + message actor Main new create(env: Env) => env.out.print(Report.stamp("generated")) env.out.print(Audit.stamp("generated"))
For this particular job the trait is arguably the better tool — it is readable, the compiler checks it, and defoverridable becomes just overriding a method. What traits cannot do is everything __using__ does beyond adding methods: registering module attributes, generating functions from a schema, or building code from data available only at compile time.
apply/3 and module introspection → nothing
Elixir can call a function whose name it computed at runtime, list a module's exports, and check whether a module exists — apply/3, __info__/1, function_exported?/3. A compiled Pony binary has no module table, no function names and no way to dispatch on a string.
IO.inspect(apply(String, :upcase, ["dynamic dispatch"])) IO.inspect(function_exported?(Enum, :map, 2)) IO.inspect(Enum.take(String.__info__(:functions), 3)) IO.puts("the name of the function was just data")
primitive Upcase fun apply(text: String): String => text.upper() primitive Downcase fun apply(text: String): String => text.lower() type Transform is {(String): String} val actor Main new create(env: Env) => // The nearest equivalent: a table of FUNCTIONS, chosen at compile time. let chosen: Transform = if true then Upcase~apply() else Downcase~apply() end env.out.print(chosen("static dispatch")) env.out.print("no name can be turned into a call")
The ~ is partial application, which turns Upcase.apply into a value you can store and pass — that is as dynamic as Pony gets, and the set of choices is fixed at compile time. Anything built on genuine runtime reflection, from plugin loading to Phoenix.Router's dispatch, has to be restructured as an explicit table of function values.
mix → ponyc and corral
mix new → a directory is a package
A Mix project is defined by mix.exs, and modules are found by name regardless of where their file sits. Pony has no project file at all: a directory is a package, every .pony file in it is part of that package, and the directory you point ponyc at must contain an actor Main.
IO.puts("mix new my_app") IO.puts(" mix.exs — project definition and deps") IO.puts(" lib/my_app.ex — modules found by name, not by path") IO.puts(" test/ — ExUnit tests") IO.puts("Build with: mix compile Run with: mix run")
use "collections" // a package from the standard library actor Main new create(env: Env) => env.out.print("my_app/") env.out.print(" main.pony — every .pony file here is ONE package") env.out.print(" parser/ — a subdirectory is a separate package") env.out.print("Build with: ponyc my_app Run with: ./my_app") env.out.print("Set[String] came from a use of the collections package: " + Set[String].size().string() + " entries")
The file-is-not-the-unit rule catches people out: there is no import of a single type and no per-file privacy, so everything in a directory sees everything else in it, and use brings in a whole package at once. Privacy is by naming convention instead — a leading underscore, as on the actor fields throughout this page, makes a name package-private.
Hex and mix deps → corral
Elixir has Hex, a curated package registry with versioning, docs and a lockfile, wired directly into Mix. Pony's corral is a separate dependency manager that vendors packages from git repositories into a local directory — there is no central registry, and the ecosystem is very much smaller.
IO.puts("mix.exs:") IO.puts(" defp deps do") IO.puts(" [{:jason, \"~> 1.4\"}, {:req, \"~> 0.5\"}]") IO.puts(" end") IO.puts("mix deps.get — fetches from hex.pm, writes mix.lock") IO.puts("Hex has tens of thousands of packages")
actor Main new create(env: Env) => env.out.print("corral.json:") env.out.print(" corral add github.com/ponylang/http_server.git --version 0.6.3") env.out.print("corral fetch — vendors into _corral/, writes lock.json") env.out.print("corral run -- ponyc") env.out.print("No central registry; dependencies are git URLs")
Be honest with yourself about this row before committing to Pony for real work. Hex has JSON, HTTP clients, database drivers, Ecto and Phoenix; Pony has a standard library that covers the fundamentals well and a community-maintained handful of packages beyond it. For systems programming that is often enough, and for a web application it usually is not.
ExUnit → PonyTest
ExUnit runs tests concurrently, prints a diff on failure, and gets its readable assertion messages from macros. PonyTest is in the standard library's pony_test package: each test is a class implementing UnitTest, and TestList registers them. Tests also run concurrently — each one is its own actor.
ExUnit.start(autorun: false) defmodule ElixirPonyMathTest do use ExUnit.Case, async: true test "addition works" do assert 2 + 2 == 4 end test "subtraction works" do assert 5 - 3 == 2 end end ExUnit.run()
use "pony_test" actor Main is TestList new create(env: Env) => PonyTest(env, this) fun tag tests(test: PonyTest) => test(_TestAddition) test(_TestSubtraction) class iso _TestAddition is UnitTest fun name(): String => "addition works" fun apply(helper: TestHelper) => helper.assert_eq[I64](4, 2 + 2) class iso _TestSubtraction is UnitTest fun name(): String => "subtraction works" fun apply(helper: TestHelper) => helper.assert_eq[I64](2, 5 - 3)
Note that Main itself becomes the test list, which is how a Pony test binary works — you compile a test directory into its own executable rather than running a task. The assertions are ordinary generic functions rather than macros, so a failure reports the two values but cannot show you the expression that produced them, which is the concrete cost of having no macro system. The Elixir cell is display-only: ExUnit is not part of the in-browser AtomVM build, and ExUnit.start/1 dies on a missing :erlang.apply/3.