A tour of how Lambda Prelude actually works — the AOT lowering, the OCaml 5 effects-based scheduler, method dispatch, and the type system. Code snippets in this page are sketches of the lowered form, not byte-for-byte output. Run lambda-prelude build script.lp --emit (or --out script.ml) to see the real thing for a given program.
AOT lowering — a walkthrough
lambda-prelude build lowers Lambda Prelude source to OCaml source, then hands it to ocamlopt. The lowering follows simple, predictable rules so the generated OCaml stays readable.
A class becomes a record + functions
Object subclass: #Counter fields: (value).
Counter class >> of: n = Counter fields: { value: n }.
Counter >> value = value.
Counter >> inc = Counter fields: { value: value + 1 }.
Lowered to OCaml (shape):
type counter = { value : int }
let counter_of (n : int) : counter = { value = n }
let counter_value (self : counter) : int = self.value
let counter_inc (self : counter) : counter = { value = self.value + 1 }
Each class is one OCaml record type. Each instance method takes self as its first argument and lowers to a top-level function. Class methods (declared with class >>) are also top-level functions; the receiver is the class itself, so there is nothing to pass.
The fields: constructor lowers to record construction, and Counter fields: { value: value + 1 } becomes the record update expression { value = self.value + 1 }. The original self is unchanged, exactly as the surface semantics promise.
Protocols become free-floating defaults
Protocol subclass: #Comparable requires: (#<).
Comparable >> > other = other < self.
Comparable >> min: other = (self < other) ifTrue: [self] ifFalse: [other].
A protocol declares which selectors it requires from any class that satisfies it (< here), and provides default implementations for the rest. Default methods lower to functions parameterised over the required operation:
let comparable_gt (lt : 'a -> 'a -> bool) (self : 'a) (other : 'a) : bool =
lt other self
let comparable_min (lt : 'a -> 'a -> bool) (self : 'a) (other : 'a) : 'a =
if lt self other then self else other
When a concrete class uses a protocol method, the compiler supplies the class's < as the first argument. The result is dictionary-passing in the Haskell sense, but flatter — no type-class hierarchy to traverse, just one explicit parameter per protocol bound.
Actor receive: becomes a typed dispatch closure
Counter class >> run: me count: n =
me receive: {
Inc -> [:msg | Counter run: me count: n + 1].
GetCount -> [:msg | msg reply ! n. Counter run: me count: n]
}.
The class-tag dispatch table lowers to a closure that pattern-matches on the message constructor:
let rec counter_run (me : actor) (n : int) : unit =
let handler : type a. a message -> unit = function
| Inc -> counter_run me (n + 1)
| GetCount { reply } ->
Effect.perform (Send (reply, n));
counter_run me n
| _ -> Effect.continue_with_no_match ()
in
Effect.perform (Receive handler)
Non-matching branches re-raise into the scheduler, which leaves the message in the mailbox and tries the next handler invocation. That is how selective receive falls out for free — the scheduler does not need to know about class-tag semantics, only about "this handler did not consume the message, try later".
Actors on OCaml 5 effects
The actor primitives are effects. The scheduler is the effect handler.
type _ Effect.t +=
| Spawn : (actor -> unit) -> actor Effect.t
| Send : actor * any -> unit Effect.t
| Recv : actor -> any Effect.t
| Recv_Selective : actor * recv_entry list -> (any * handler) Effect.t
Actor spawn: [:me | body] performs Spawn. The scheduler allocates an actor record (mailbox + fiber state + monitor list), creates a fresh continuation seeded with the body, pushes it onto a run queue, and returns the actor handle to the caller.
actor ! msg performs Send. The scheduler appends msg to the target's mailbox tail. If the target is currently parked on Receive, the scheduler reschedules it.
actor receive performs Recv; the scheduler returns the mailbox head (FIFO). Selective receive: performs Recv_Selective with a handler table: the scheduler walks the mailbox head-to-tail and consumes the first message a handler matches, resuming the actor's continuation with the handler's return value. If no message matches, the fiber parks. (Timeout variants perform Recv_Selective_Timeout.)
The scheduler is a small self-contained piece of OCaml — a run queue, a domain pool, a mailbox queue per actor, and the effect handler. There is no separate async library, no monad, no I/O loop owned by an external framework.
Multi-domain work-stealing
The scheduler runs across N OCaml domains (following OCaml 5's recommendation, typically one per core). Each domain has a local run queue; idle domains steal from busy peers. Cross-domain message sends use a lock-free MPSC queue per target mailbox, so producers from any domain can enqueue without blocking the consumer's domain.
The same scheduler is compiled into the AOT binary, so it has the identical multi-domain work-stealing behaviour — there is no separate single-domain runtime. See AOT.
Parking on I/O
TCP readLine:timeout: issues a non-blocking read; if the read returns EAGAIN, the scheduler records the file descriptor in its readiness set, parks the fiber, and yields. The scheduler's idle path drives a Unix.select-based readiness loop (portable across Linux and Windows). When the OS reports readiness, the scheduler wakes the parked fiber and re-runs the read. From the caller's view, the call just blocks. The OS thread is free to run other actors meanwhile.
TLS is the same shape: TLSSocket >> readLine:timeout: drives Tls.Engine directly. Engine state advancement may want bytes (do a TCP read), may want to write (do a TCP write), or may produce application data; each "want" parks on the corresponding I/O event. Handshake, application read, and write all park separately.
Method dispatch
Each call site lowers to one of two forms; the compiler picks the cheapest one available.
Statically resolved sends (the common case)
If the receiver class is statically known and the selector is monomorphic at that class, the send lowers to a direct OCaml function call. No vtable lookup, no inline cache, nothing at runtime.
c := Counter of: 41.
c inc value printNl.
Lowers to:
let c = counter_of 41 in
print_endline (string_of_int (counter_value (counter_inc c)))
This is the fastest possible dispatch in any language family. The hot path of well-typed code has zero dispatch overhead.
Runtime-dispatched sends
Where the receiver class cannot be pinned down statically (Remote ref, perform:, reflection, plugin-loaded classes), the send is lowered to a call into the runtime dispatcher, Eval.dispatch. There is no inline cache: dispatch looks the selector up in the receiver class's method table and walks the cls_super chain up to Object.
let dispatch (recv : value) (sel : selector) (args : value list) : value =
let ci = class_of recv in
match lookup_method ci sel with (* method table + super chain to Object *)
| Some m -> invoke_method m recv args
| None -> does_not_understand recv sel
lookup_method consults the class's selector → method Hashtbl, falling back along the superclass chain. Inline caches are deliberately out of scope for the current version — the tree-walk method table is the baseline, and AOT removes the lookup entirely wherever the receiver class is statically known, so the runtime dispatcher only ever sees the sites inference could not pin down.
Type system
Hindley-Milner with three extensions: protocol bounds on type variables, row variables on actor types, and per-instance field types on records and Dict literals. Inference is complete; annotations are optional.
Protocol-bound polymorphism
The inferred type of Comparable >> max: is:
∀α. Comparable α ⇒ α → α → α
Any class that satisfies Comparable (i.e., implements <) can use max: without further declaration. The protocol bound is enforced at compile time:
"This compiles — Counter satisfies Comparable."
(Counter of: 3) max: (Counter of: 7).
"This fails to compile — String does not declare Comparable."
'abc' max: 'def'.
Adding Comparable to String's protocol list at the class declaration site would make the second example compile, without touching max: itself.
Actor row variables
An actor's receive: accepts a set of message classes. The type of an actor reference carries that set as a row:
counter : Actor { Inc, GetCount | ρ }
The | ρ part is a row variable — the set of message classes the actor might additionally handle, inferred from usage. The row participates in unification: if another piece of code sends Boom to counter, inference attempts to unify Boom into ρ, fails if counter's actual handler set does not accept it, and the build is rejected.
Practically: sending an Inc to the counter type-checks; sending a Boom to a counter that only handles Inc and GetCount is a compile-time error pointing to the offending send.
Record and Dict field types
User-class instances and Dict literals carry their field / key types as a row, inferred per construction site.
A Cls fields: { k: v, ... } construction is typed TInstR(Cls, { k: <type of v>, ... }), and a class's instance methods carry a per-method field row in their self type, so a field read returns the type the constructor put in. Because the row is per construction site, the same class reads at a different field type per instance — (Box of: 42) get is Integer while (Box of: 'hi') get is String, and building a Box with a String then using that field as an Integer is a compile-time error. Across a nominal boundary — a parameter or return annotated as the bare class (TInst Cls) — the field types are forgotten while the instance still flows in.
A Dict from: { k: v, ... } literal is typed by its keys, so d at: 'age' returns key age's value type. The precision holds while the receiver keeps the typed-literal form and the access key is a string literal in the key set; it stops once the key is computed, the value crosses into a slot typed as the plain Dict, or two typed dicts unify (collected into one Array, say). Heterogeneous dicts are the normal case, so per-key types are never merged into a union.
Why these extensions
Together, these extensions let an actor body be written as plain class methods and let field-typed instances flow through ordinary code, all checked by the same inference that covers the rest of the program. There is no separate "actor type checker" or runtime contract — actor message dispatch is a typed send like any other, and protocol-based polymorphism lets a single handler serve many concrete receivers.
Standard library data structures
Two non-obvious choices in the core library:
Dict is a HAMT
Dict is a hash-array-mapped trie, persistent: at:put: returns a new Dict with O(log₃₂ N) sharing of the old. Updates do not invalidate concurrent reads, which matters when an actor's loop arg is passed forward as count: n while another fiber holds a reference to an older snapshot.
Array is a 32-way persistent trie
Array is the same shape — a wide persistent vector with O(log₃₂ N) updates and constant-factor-cheap indexing for the common case. Same persistence story as Dict.
These data structures are what make Lambda Prelude's immutable-by-default surface feasible at backend-service scale: an actor receiving 10 000 messages can pass a 10 000-entry table forward through its loop without copying the table.
The TLS bridge
Tls.Engine is the pure-OCaml TLS state machine from the ocaml-tls library. Lambda Prelude wraps it so the engine drives, but I/O parks on the scheduler.
Caller Engine TCP socket
── ────── ──────────
TLSSocket readLine:
→ wants_app_data
next_event
→ WantsRead
← park on TCP fd readable
← bytes arrive
handle_bytes
→ ProduceAppData
← bytes returned to caller
Each wants_read / wants_write from the engine parks the calling fiber on the corresponding I/O wait. The engine itself runs synchronously in user space; there is no separate TLS thread, no callback-based I/O.
Handshake, application read, write, close — all four phases use the same shape. This is why a slow TLS upstream cannot pin the scheduler: every place the engine wants bytes, the fiber parks, and the OS thread runs other actors.