Lambda Prelude is shaped by hard "no"s. Each removes a category of confusion from production service code. The cost is that some patterns familiar from other languages have to be rewritten — a price worth paying when the resulting code reads top to bottom on the SVO axis (subject, verb, object) and stays readable as the service grows.
This page documents what was rejected, and why.
No pattern matching at the surface
(score > 90)
ifTrue: ['A' printNl]
ifFalse: ['B' printNl].
Not:
match score > 90 with
| true -> print_endline "A"
| false -> print_endline "B"
A Smalltalk-style program reads top to bottom, each line a message send to a named receiver. A pattern-match line breaks that flow — the reader's eye has to scan two branches simultaneously, and the relationship between the matched value and the surrounding state has to be reconstructed from the column structure of the branches.
Every conditional in Lambda Prelude is a message send: ifTrue:ifFalse:, ifPresent:ifAbsent:, ifEmpty:ifNotEmpty:. Every retry / timeout / option-destructuring is the same shape.
The cost: ADT-style "one shape, many branches" code has to be rephrased as "many classes, one dispatch". This is rarely a real cost — most ADTs in long-running services are open hierarchies (HTTP responses, JSON values, RPC results, message types) and benefit from the open-extensibility of class-tag dispatch.
No class inheritance — protocols only
A class declares which protocols it implements; it inherits no concrete behaviour from a parent.
Object subclass: #Score
fields: (points)
protocols: (Comparable, Printable).
Inheritance hierarchies grow in the direction of accidents, not in the direction of design. A class added today inherits behaviour that was useful for last year's class; nobody dares delete the parent because three other classes might depend on it through transitive inheritance.
Protocols are explicit. A class lists the protocols it satisfies. A protocol lists the selectors it requires. Default methods on a protocol are visible to every class that satisfies the protocol — but the relationship is named, not implicit through chain-walking.
The cost: helper methods that span multiple classes have to be moved to a protocol or duplicated. In practice this is good — duplication that doesn't share semantics shouldn't share code, and naming the shared semantics surfaces design decisions that inheritance would hide.
No instance variables — fields: records
Each object is a closed record of named fields. Updating a field means returning a new object.
Counter >> inc = Counter fields: { value: value + 1 }.
Mutable instance variables encourage state churn inside objects — methods that read field A and write field B in non-obvious order. The order matters; reordering breaks semantics; the order is not visible in the method's call signature.
Immutable records force the data flow to be on the surface: every method that "changes" state takes a value in, returns a new value, and the caller decides what to do with it. This is the same discipline a programmer reaches for in a functional language; Lambda Prelude just makes it the only option in surface code.
The cost: incremental updates of large objects have to be expressed as new objects. Persistence (HAMT Dict, 32-way trie Array, structural sharing on records) keeps the actual cost flat in practice — see Internals for the data structure rationale.
No metaclasses
Classes are types. Class methods are associated functions, not methods on a metaclass.
Counter class >> of: n = Counter fields: { value: n }.
Counter class >> of: lowers to a top-level counter_of function. There is no Counter class class and no class of: someClass.
Reflection that needs to walk classes (System allClasses, String methodSelectors, dynamic class lookup) is kept out of the normal dispatch path and exposed through builtins. Reflection is ambient — those builtins are ordinary calls today, not gated behind an effect. Surfacing reflection as an interceptable effect, so a handler can audit, deny, or virtualize reflective operations, is future work.
The cost: code that wants to treat classes as first-class values has to go through the reflection API. The benefit: the static type system covers 100% of normal program flow without holes for "what if someone meta-changes the class table".
No non-local return
Smalltalk's ^ returns from the enclosing method, even from inside a block passed to another method. This is a source of bugs across actor boundaries — when a block runs in a different fiber, the "enclosing method" may have already returned.
Lambda Prelude replaces ^ with effect-based early exit:
Loop with: 0 do: [:state |
(state > 10) ifTrue: [Break value: state].
state + 1
].
Break value: performs an effect that the enclosing Loop with:do: handles. Cross-fiber, the effect handler is the fiber's own — escaping outside the fiber is impossible, and the type system makes the escape route visible in signatures.
The cost: idioms that depend on ^ (early-return helpers, custom control flow) have to be rephrased using Break, Exit, or Return effects. The benefit: programs that span actors stay consistent — there is no "this looked like an early return but the method was already gone" failure mode.
No nil — absence is a Maybe
There is no nil in the surface language. A lookup that may miss returns a Maybe, consumed with ifPresent:ifAbsent::
port := (OS getenv: 'PORT')
ifPresent: [:p | p asInteger]
ifAbsent: [8080].
Maybe is a parameterised builtin — none, some:, ifPresent:ifAbsent:, isPresent. A method with no meaningful result returns self (Smalltalk has no void), so absence never leaks out as a sentinel.
The cost: code that reached for a raw nil to mean "absent" has to name the absence as a Maybe and destructure it. The benefit: a value's type tells you whether it can be absent, the type checker forces every caller to handle the absent branch, and the "method returned nil and the next send blew up three frames later" failure mode disappears.
OCaml 5 effects, not Eio / Miou
Lambda Prelude is first-class on Windows (MinGW-w64) and Linux (x86-64). Other targets that OCaml 5 supports (macOS, ARM64, MSVC) are expected to build but are not part of the regular regression matrix yet. No POSIX-only primitives, no Windows-only primitives, no platform-specific async runtimes.
Eio and Miou are full async frameworks with their own runtime models — depending on either would bind Lambda Prelude to that framework's lifecycle decisions (fiber semantics, cancellation policy, structured concurrency choices, dependency surface). The actor scheduler is a small self-contained piece of OCaml using Effect.t and a custom run queue. Easier to read, port, and debug.
The cost: when something async needs to be added (a new I/O primitive, a new timer), Lambda Prelude has to implement it on the raw effect handler. The benefit: there is no second framework to learn, no version-matching dance with an external scheduler, and the entire concurrency story fits in the head of one engineer.
Tree-walk interpreter + AOT, not bytecode VM
The interpreter walks the AST directly. The AOT compiler lowers AST → OCaml source → ocamlopt → native binary. No bytecode VM, no JIT.
A bytecode VM is significant infrastructure to maintain for what is, in production, a one-time compilation step. AOT to OCaml inherits all of ocamlopt's compiler engineering — closure conversion, inlining, register allocation — at no additional cost. AOT builds already compile under dune's release profile, so binaries get -O3. Heavier optimisation (flambda2, aggressive specialisation) is a possible future direction, not a separate user-facing build flag.
The interpreter exists for iteration speed during development: fast load, fast retry, easy to extend with a new builtin. When the interpreter and AOT disagree, the AOT binary is canonical — interpreter behaviour that doesn't match AOT is treated as an interpreter bug.
The cost: deploying Lambda Prelude means deploying a native binary, not a script. Scripts that worked under the interpreter and not under AOT are bugs in the interpreter, not features of the language.
Virtual actors for distribution, not Erlang clustering
Lambda Prelude's local actor model — spawn, link, monitor, trapExits:, supervision — does not extend across machines. There is no cross-node spawn, no distributed link cascade, no global supervision tree.
What Lambda Prelude provides instead: a remote reference (Remote at:for:id:) that proxies a method send over JSON-RPC — statically typed when given a literal class, so an unknown selector is a compile-time error (a non-literal for: stays dynamically typed) — plus a virtual-actor (grain) backend that ships today, where state lives in an external KV (Redis, etc.) per call and a Redis lease with a fence token enforces cross-node single-activation. Each remote method invocation is a stateless RPC; correlation and supervision stay local.
Distributed-actor models (Erlang clustering, Akka Cluster) carry strong correctness guarantees about message order, link cascades, and process identity across nodes — guarantees that hold only under specific network conditions and that require operating two intertwined systems (the actor runtime and the cluster manager).
Lambda Prelude's target audience is the operator running a few resident services with a database, not a cluster operator. The virtual-actor approach is a smaller, more honest contract for that scale: a remote ref is a typed proxy to a class, state reads / writes go through an external KV, and the failure model is the failure model of the network and the KV — not a special distributed-actor failure model.
The cost: code that wants Erlang-style cross-node spawning and supervision cannot use Lambda Prelude. The benefit: code that uses Lambda Prelude within a single binary gets the full local actor story (selective receive, link, monitor, trap-exits, supervision), and code that needs to talk to other binaries does so through a contract that is the same shape whether the other binary is Lambda Prelude, Java, Go, or Python.
Static types from day one, not opt-in gradual typing
Type inference is mandatory; annotations are optional. There is no untyped fallback, no Any, no dynamic mode for "just get this script running".
Gradual typing systems pay a runtime cost (type tags, narrowing checks, wrapper objects) for every value that crosses a typed / untyped boundary. The cost is rarely measured because the dynamic mode is treated as the "real" mode and the typed mode as an aspirational future state.
Lambda Prelude picks the other end: typed is the only mode. The interpreter and AOT both type-check before evaluation. Programs that won't compile under the type checker won't run at all.
The cost: there is no way to "just sketch something" without satisfying the type checker. The benefit: programs that compile run; runtime type errors as a category do not exist for normal sends; the failure surface shrinks to I/O, external services, and user-provided invariants.
Why these no's and not others
Some patterns rejected by other functional languages are kept in Lambda Prelude:
- Side effects on I/O are not banned.
File write:,Log info:,actor !are direct, not wrapped in aResultmonad. The effect system handles non-local control flow; I/O effects don't need monadic plumbing. - Mutable closures are allowed inside actors. State changes are confined to the actor's loop argument; the closure that defines the actor body is allowed to refer to its own bound names by ordinary lexical scope.
The criterion: a construct is banned when it hides data flow at a distance — across files, across method dispatch chains. Inheritance hides which parent method actually runs; pattern matching scatters a value's branches across non-adjacent columns. Both force the reader to reconstruct flow from context that lives somewhere other than the line being read.
A construct is kept when its effect is visible on the line where it appears. printNl writes a line to stdout. actor ! msg enqueues a message on the named mailbox. An actor's loop argument captures state and forwards it to the next iteration. In every case, what the line does is what the line says — no walking the program to reconstruct it.