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.

Resolving a send — deferral is the last answer

The type system is defined by a normative specification rather than by its implementation, and the position it states is: what can be determined is checked, what cannot is left to run time, and nothing is left to run time that the program has already made unambiguous.

When the receiver's type is known, the signature is looked up by its shape: Class[C] in C's class-side methods, C in C's methods and then the protocols C implements, Remote[C] in Remote's own methods and then C's. There is no user class hierarchy, so there is no chain to walk — a selector is found on the class itself, on a protocol it implements, or on Object, whose methods are universal.

When the receiver is still a type variable, it is pinned in this order:

  1. Object. If Object declares the selector, that is the signature; it is universal and imposes nothing on the receiver.
  2. A protocol the receiver already carries. If one of the variable's constraints declares the selector, that is the signature.
  3. Protocol induction. Among the protocols visible at that site, if exactly one declares the selector, the receiver is a value implementing it — which is what the send asserted. Two or more is ambiguous, and an annotation says which was meant.
  4. One class, one reading. If exactly one class answers the selector, the receiver is a value of that class: there is no subtyping and no other definition to mean instead. This is what keeps checking alive after an unresolved comparison — only Boolean answers ifTrue:ifFalse:, so its receiver is a Bool and the two branches are brought together.
  5. The arguments decide. If several classes answer it, each is tried against the arguments as given; if exactly one admits them, that one is meant. n < 2 is the ordinary case — Integer's < takes an Int, Float's and String's do not.
  6. Otherwise that one send is left to run time.

A block-call selector (value:, value:value:, ...) is never deferred: the receiver is unified with a block type of matching arity, so a callback invoked through a parameter still has its argument count checked.

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.

Generalization runs per dependency group

Methods are generalized per dependency group — the strongly connected components of the call graph, callees first — not per module. A helper called at two different types from the same file therefore stays polymorphic, and sharing a helper stops dictating module layout. Mutual recursion forms one component, so it still shares a single group and generalizes together.

Extending the root class

A method defined on Object takes a polymorphic self and is callable on any receiver, the way the builtin universal methods are. The root class can be extended from any module, under two guards that keep dispatch unambiguous: a selector Object already answers cannot be redefined, and two modules cannot claim the same selector on Object.

A binding is typed before its body is read

x := expr. body and [:x | body] value: expr both bind x to expr's type before the body is read, so a send to x is checked against the class it actually holds — a selector that class does not have, or a use that contradicts the type it was built with, is a compile-time error at the line you wrote it on rather than a runtime surprise. A block handed to a higher-order method is typed the same way, from the receiver: in (anArray collect: [:x | ...]), x is bound to the element type before the block is read.

Typed immutable update

inst copyWith: { field: value, ... } answers a new instance with the named fields replaced and the rest carried over. Field names are checked against the class declaration and each value against that field's type, which the reflective atField:put: cannot do. Actor state held in one record no longer costs a constructor call per updatable field, and no mutability is introduced to get there.

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.