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.