PatLang Paradigms Guide
For new readers
A "paradigm" here just means a style of writing code — functional (functions passed around as values), object-oriented (objects with state and methods), goal-oriented (describe what you want, let the language search for how), and so on. Most languages pick one and treat the rest as an afterthought; this page catalogues nine that PatLang supports as first-class, each with its own section below and a runnable example. If a term below is unfamiliar, the Grammar & Syntax page defines the exact syntax, and Use Cases shows several paradigms combined in one program, which is how most real PatLang code actually looks.
PatLang is not built around one programming model with the others bolted on as libraries. Nine paradigms sit at roughly equal weight in the language and its host-function surface, each with real working example code in the repository. This page walks each one: the keywords or host functions involved, why you'd reach for it, and a minimal worked example. See the Grammar & Syntax reference for exact syntax and the Use Cases page for larger programs that combine several of these at once — most real PatLang programs do. If you'd rather try things out than read about them, the live REPL runs entirely in your browser, no install needed — or run self_hosting/tools/repl.patlang yourself for the CLI form. The hex RTS demo is a bigger worked example in the same spirit — a real, click-to-play hex-grid gather-and-build sim, built around a pure step(state, orders) -> new_state function rather than the REPL's replay-the-whole-session model, since a live game driven by continuous clicks needs bounded per-tick cost, not O(n) replay growth. The SQL console goes further still — a genuine transactional database, CSV-backed tables over the virtual filesystem, single-writer BEGIN/COMMIT/ROLLBACK, and a hand-rolled SQL parser (CREATE/INSERT/SELECT with WHERE/AND and one JOIN/UPDATE/DELETE), all callable live from a tabbed browser console.
Functional
Functions are values. apply(fn, args) and closures (|x| { ... } / |x| do ... end) give map/filter/reduce-style composition without dedicated syntax for any of them — they're ordinary higher-order function calls.
let square = |x| { x * x }
let squared = apply(square, [1, 2, 3])
Reach for this when a transformation is naturally a pure function of its input and you want to pass it around rather than name a statement block.
Object-Oriented
Objects live in a single flat, string-keyed store, manipulated through host functions in the oo chunk: new, set_var, get, send. That store-based core is still the whole foundation — it's what a real class declaration compiles down to, not a separate parallel mechanism.
let acct = new("Account")
set_var(acct, "balance", 100)
send(acct, "deposit", [50])
print(get(acct, "balance"))
On top of that, PatLang now has a real, optional class keyword with single inheritance, genuine method dispatch, and composable traits — added deliberately as sugar over the same store, not a competing type system:
class Animal {
field legs = 4
make a function called speak takes self returns msg
return "generic animal noise"
end
}
class Nameable {
make a function called label takes self returns msg
return "name:" + get(self, "name")
end
}
class Person inherits Animal do
traits Nameable
field legs = 2
make a function called speak takes self returns msg
return "hello"
end
end
let p = new("Person", "p1")
print(send(p, "speak")) # "hello" -- Person's own method wins
print(send(p, "label")) # "name:p1" -- inherited from the Nameable trait
Resolution order is own class > traits (last-listed wins on a collision between traits) > parent, applied recursively up the (single) inheritance chain, for both field defaults and methods. The class block accepts either { } or do ... end (begin also works as a synonym for do here, matching every other dual-form block in the language). A class declaration is entirely optional — a formal step up from the ad hoc new("Literal", "id") style above, not a requirement it replaces: new(...) can also register or extend a class inline, with zero declarative block at all, via trailing keyword-style arguments — new("Person", "p1", "inherits", "Animal", "traits", ["Nameable"]) — which is how you compose inheritance and traits into an object without ever writing a class block. Method dispatch is real: send(obj, "method", args...) invokes a genuine user-defined closure when one resolves for the object's class, falling back to the built-in "set" verb only when nothing does — the same object-store new/get/set_var/send primitives above still work exactly as they always did for any class name that's never been formally declared.
Reach for the plain store primitives when you want mutable, named state with no need for shared structure between object "kinds" — the point-of-sale demo's product catalogue is a representative case. Reach for class/inherits/traits when several kinds of object genuinely share fields or behaviour and naming that relationship explicitly (rather than duplicating it by hand at each new(...) call) makes the program clearer to a human reader.
Event-Driven
when EVENT { ... } (Stage 0) or when EVENT do ... end (both stages) registers a handler; emit fires it — synchronously, on whichever thread calls emit, in the order handlers were registered.
when "scan" do
print("item scanned")
end
emit("scan", [])
Cross-thread since this session's fix: the compiled/codegen backend used to store the handler registry (EVENT_HANDLERS) as a thread_local!, so every real OS thread a program spawns — a fiber thread, a parallel_map worker, a WASI-threads Worker — started with its own fresh, empty copy: calling emit(...) from inside a fiber was a silent no-op, even for handlers registered before the fiber was spawned. It's now a shared, Mutex-protected registry (gated on target_feature = "atomics", the same signal used for fiber support itself), so a handler registered anywhere fires correctly no matter which thread calls emit. Dispatch itself is unchanged — still synchronous, same call stack, whichever thread emitted runs the handler body directly. See the robots demo for a live, hands-on example: three real fibers signaling a main-thread handler as they navigate a grid. The object store (new/get/set_var, backing send) got the same fix later the same session — it was also thread_local!, found while building the runtime signals work below, and is now cross-thread-safe too.
Handlers are genuine closures now: a when EVENT { ... } block used to be synthesized into an isolated, standalone function with no access to anything outside it — a handler declared after a let simply couldn't see it, worked around by re-declaring the same name fresh inside the handler body. It now lowers through the same closure machinery ordinary |x| { ... } closures use, registered at runtime (via register_event_handler) at the point the when statement actually executes, so a handler correctly captures any enclosing let declared before it — the workaround above is no longer necessary.
Known gap, found while building parallel_map-based demos: a function invoked via parallel_map (or a fiber) runs on a worker thread with its own fresh interpreter state that jumps straight into that function — it never executes the program's top-level let statements first, so a top-level constant referenced from inside such a function silently doesn't resolve. Not yet fixed; work around it by inlining literals or passing values as explicit parameters into parallel_map-invoked functions rather than closing over a top-level let.
Reach for this when control flow is naturally driven by external occurrences rather than a single top-to-bottom script — servers, UIs, simulations with a timeline.
Runtime Signals
A running PatLang program can receive a named signal + optional payload from outside itself — a second CLI invocation (server.exe --quit sent to an already-running server.exe, not a new one) or a local network call — without any new dispatch concept: it's built entirely on top of the Event-Driven paradigm above. self_hosting/lib/signals.patlang's signal_poll just calls emit(name, payload) when a signal arrives, so any when <name> do ... end handler already in the receiving program fires exactly as if the event had originated locally.
include "lib/signals.patlang"
when quit do
set_var("should_stop", "1")
end
when status do
signal_reply("uptime: " + get("__vars", "uptime"))
end
let port_id = signal_claim(9401)
if port_id >= 0 then
# primary: hold the port, poll it inside your own loop
while get("__vars", "should_stop") == "0" do
... your own work ...
signal_poll(port_id, 20)
end
else
# secondary: someone else already owns this port
signal_send(9401, "quit", "") # fire-and-forget
print(signal_query(9401, "status", "")) # request/response
end
The mechanism relies on one existing fact rather than any new primitive: tcp_listen already binds 127.0.0.1 only, and a second bind to the same port simply failing IS the "a primary instance is already running" detector, for free — no lockfile or PID file (PatLang has neither). The one genuinely new host function this needed was tcp_try_listen(port), a non-panicking bind returning -1 specifically on "already in use": PatLang has no try/catch, so plain tcp_listen's bind failure is fatal, and there was previously no way to gracefully ask "is something already listening" at all.
Two kinds of signal: fire-and-forget (signal_send, e.g. quit) delivers and moves on without waiting for a reply. Request/response (signal_query, e.g. status) blocks for whatever the primary's handler passes to signal_reply(text) — useful for a program to report genuine live state (an uptime counter, a job's progress) back to whoever asked, not just a canned string. See the signals demo for both in action, including a worked status query reporting real tracked state.
Localhost-only by construction, so there's no separate auth story yet — a real network-facing signal listener would need one before ever binding beyond 127.0.0.1.
Reach for this when a long-running process (a dev server, a build daemon) needs graceful external control — shutdown, a live status query — without hand-rolling IPC from scratch.
See the build daemon exemplar for signals combined with logic, goal-seeking, events, and self-timing in one real system — the daemon's own status query reports a live dashboard built from all five working together, not just this paradigm in isolation.
Logic / Goal-Oriented
This paradigm is genuinely real now, not a facade over function calls. The old flat trio — fact/query/goal as call-form host functions — is kept for backward compatibility, but the real engine underneath is rule_add/solve: proper SLD-style backward-chaining resolution with backtracking. A ground fact is just a rule with an empty body (the standard Prolog trick), and a bare capital-starting identifier (^[A-Z]) used as an argument is a logic variable by convention, resolved by solve. Both stages support this: Stage 0 natively, and Stage 1 (the self-hosted patc1 compiler) mirrors it too.
rule_add("dep", ["a", "b"], [])
rule_add("unchanged", ["b"], [])
rule_add("buildable", ["X"], [["unchanged", ["X"]]])
rule_add("buildable", ["X"], [["dep", ["X", "Y"]], ["buildable", ["Y"]]])
solve("buildable", ["a"]) # -> backtracks through the dep chain, finds a solution
There's also real declarative surface syntax for the same backend — rule Head(args) :- Body1, Body2. — pure sugar lowering to exactly the rule_add call sequence above; see the Grammar reference and the rule syntax demo.
Alongside backward chaining, action_add/plan is a real GOAP (goal-oriented action planning) engine — uniform-cost forward search over actions with preconditions, add/delete effects, and cost, finding the cheapest ordered plan rather than just the first path that works. Actions can be parameterized too, using the same ^[A-Z] variable convention as rule_add/solve — a single build(X) action template grounds into a separate instantiation per matching fact (e.g. build(X=target_a), build(X=target_b)), conjunctively unifying its preconditions against the current world state rather than needing one hand-written action per target. A fully ground action (no variables) still grounds to exactly one instantiation, so this is a strict superset of the original ground-only behaviour. And a successful require/ensure contract check asserts a contract_holds fact, so design-by-contract checks feed directly into either engine. See the goal-oriented demo for a worked example of both engines together.
The goal name { ... } block syntax remains a deliberate no-op — there's no unambiguous mapping from a goal block onto solve/plan yet (is it a named query? a GOAP action declaration? imperative code that runs when "pursued"?). Don't rely on it.
Reach for this when a problem is naturally a search over facts and relationships rather than an imperative walk — deriving whether a build target is reachable via a multi-hop dependency chain, or planning an ordered sequence of actions toward a goal state, rather than hand-writing that traversal imperatively. See the build daemon exemplar for both engines put to real use, with GOAP costs driven by genuinely measured historical timing rather than arbitrary numbers.
Design by Contract
require (precondition), ensure (postcondition), and bare assert are genuine, shared grammar in both stages, parsing to one Assert statement distinguished only by a kind tag. All three lower, at codegen and interpreter time alike, to a single host call: contract_check(func_name, kind, text, ok). One primitive backing three keywords keeps failure messages uniform ("precondition failed", "postcondition failed", plain assertion text) regardless of which of the three actually fired.
make a function called divide takes a, b returns q
require b != 0
let q = a / b
ensure q * b == a
return q
end
Reach for this when a function's correctness depends on caller-supplied invariants you want checked at the boundary rather than defended against defensively inside the body.
Durable Message Queue
PatLang has no try/catch, so a failing host call — a bad file path, a refused connection — is fatal to the whole program, and emit is a plain synchronous in-process function call with no persistence or cross-process reach. self_hosting/lib/queue.patlang fills the resulting gap without touching either of those primitives: a durable, file-backed message queue, built entirely in PatLang on top of read_file/write_file/file_exists — no new host functions, consistent with keeping new capabilities in the self-hosted language rather than growing the Rust runtime's surface area. One append-friendly log file per topic (id/status/payload lines, pending or acked), read-modified-written whole on every call — the same convention the build daemon exemplar's history file already uses.
include "lib/queue.patlang"
let id = queue_publish("orders", "order-42 checkpoint")
let pending = queue_pending("orders") # -> [id], survives a crash or restart
let payload = queue_consume("orders") # oldest unacked payload, "" if none
queue_ack("orders", id)
Because every call writes straight through to disk rather than batching, durability and restart-resumption are true by construction: queue_pending after a crash simply reflects whatever was last written to the log file — there's no separate save/load step to remember. This gives a real (if manual, not automatic) save-on-pause / resume-on-restart story for long-running programs: a when Pause do ... end signal handler needs to do nothing beyond exit cleanly, since nothing unacked is ever only in memory.
queue_attempt(topic, payload, action) wraps the saga/compensating-transaction pattern directly in the shape of design-by-contract: publish an intent (echoing require), call action() to attempt the risky work, ack the outcome (echoing ensure) once it returns normally. If action aborts — PatLang's existing fatal-on-error behaviour is completely unchanged by this library — the intent message is simply left pending on disk. A later run, or a dedicated recovery pass, calls queue_pending and decides whether to retry or compensate. This is recovery by a separate, later call reading durable state, not stack-unwinding back to the original call site — genuine try/catch would need PatLang to grow real non-fatal, inspectable contract failures, a much bigger change this deliberately avoids.
make a function called risky_write returns r
write_file("orders/42.json", order_json)
return true
end
queue_attempt("orders", "writing order 42", risky_write)
# if risky_write aborts, "writing order 42" stays pending for the next run
Two independently-run PatLang processes publishing/consuming the same topic on a shared queue file are already a minimal decoupled pub/sub system — a lightweight, same-machine microservice style falls out of the primitive for free, without being a separately built feature.
No locking primitive exists in PatLang today, so concurrent writers to the same topic from separate processes at the same instant can race — the same accepted limitation the build daemon's history file already lives with. The idiom, not a workaround: give every concurrent writer its own topic (queue_writer_topic(base, writer_id)) rather than sharing one, avoiding the race by construction; readers that need the combined stream fan out across that whole family with queue_pending_multi/queue_consume_multi. See the Best Practices page's writeup of this idiom and the queue recovery demo, which puts it together with a genuine crash (a real fatal host error, verified across four separate OS processes) and a genuine recovery pass.
Reach for this when a step is I/O-risky and you want its intent recoverable after a fatal abort, or when a long-running program should genuinely survive being paused and restarted rather than losing in-flight state.
Numeric Tower
PatLang's Value type includes Int, Float, BigInt, Rational, and Complex — but none of these has dedicated literal syntax. You always write plain digits; promotion happens automatically at runtime. Three rules matter in practice:
- Int overflow promotes silently to BigInt. A factorial that outgrows
i64keeps computing correctly rather than wrapping or erroring. /on integers that don't divide evenly promotes to an exactRational, never a truncated integer and never a lossy float.7 / 2is the exact fraction 7/2, not3and not3.5. This is the single most common surprise for anyone arriving with C-family intuitions about integer division — see the worked bug in the Idioms & Patterns page.sqrtof a negative number promotes toComplex;sqrtitself explicitly rejects aComplexinput rather than attempting a complex square root.
Reach for this when exactness matters more than raw speed — financial or combinatorial code where a silently-truncated division or a silently-overflowed integer would be a real bug, not a rounding nicety.
Runtime-Extensible Syntax DSLs
syntax NAME { ... } blocks let a program declare new trigger tokens that are expanded against raw source text before lexing — a genuine runtime capability, not just a compile-time macro trick, as the dynamic-syntax demo specifically sets out to prove. The router DSL is the clearest example: a small HTTP routing grammar declared entirely in PatLang source, expanded, then compiled normally.
Reach for this when a sub-problem (routing tables, a small config language) is genuinely better expressed as its own compact notation than as calls into a general-purpose API.
Concurrency: Real Threads, Cooperative Fibers, and Time-Budgeted Blocks
Three distinct, honestly-different concurrency primitives exist side by side:
parallel_map(items, "func_name")is genuine OS-thread parallelism — one realstd::threadper item, viastd::thread::scope, so worker threads can borrow the program directly without cloning. This is where actual concurrent execution happens.fiber_new/fiber_resume/fiber_yield/fiber_aliveare cooperative, Ruby-fiber-style coroutines. Each fiber does get its own OS thread under the hood, but a mutex/condvar handshake ensures only one fiber's thread is ever unparked at a time — it is deliberately not concurrent. The parked thread's own native call stack serves as the saved coroutine state, which is why no separate stack-switching machinery was needed to build it. Fibers now work identically across all three execution paths — interpreted, natively compiled (pat --patc), and self-hosted compiled (patc1.exe) — the compiled paths port the same mutex/condvar design directly into the generated program's own runtime text.budgeted(ms[, existing]) { ... }/do ... endis sugar built on fibers: a block runs on an implicit fiber and yields once its millisecond budget is exhausted — or, using a rolling window of recent iteration durations and a linear-regression trend fit, once the next iteration is predicted to blow the budget, so a loop trending slower gets caught before it overruns rather than after. It evaluates to a tagged result,["done", value]or["paused", fiber_id]; passing thatfiber_idback in as the second argument resumes the same fiber with a freshly refreshed deadline, continuing exactly where it left off rather than restarting.
None of the three is routed through the general HOST_CHUNK_TABLE chunk system that everything else in this guide uses — all are special-cased directly in the interpreter and Rust codegen (and, for budgeted/fibers, mirrored into self_hosting/lib/runtime_rs.patlang for the self-hosted compiled path too). Captured locals in a budgeted block are by-value, exactly like this language's closures: mutations inside aren't visible to the caller after the block runs — use the object store (new/get/set_var) for state that genuinely needs to be shared across the pause boundary.
let results = parallel_map(work_items, "process_item") # real parallelism
let f = fiber_new("producer")
let v = fiber_resume(f, []) # cooperative handoff
let mut handle = false
let mut done = false
while not done do
let r = budgeted(16, handle) do
while some_condition do
process_heavy_data()
end
end
if r[0] == "done" then done = true else handle = r[1] end
end
Reach for parallel_map when independent items of work genuinely benefit from wall-clock parallelism (map-reduce-shaped problems). Reach for bare fibers when you want producer/consumer-style suspend-and-resume control flow and don't need — or specifically want to avoid — true concurrency. Reach for budgeted when a single loop needs to timeslice itself inside a cooperative scheduler without you hand-rolling the deadline-check-and-yield logic yourself.
WASM threading is real now, on a second, opt-in target: wasm32-wasip1 (this project's default WASM target) still has no real OS-thread support, so fibers — and therefore budgeted — still return a clear runtime error there. But wasm32-wasip1-threads (nightly toolchain, -C target-feature=+atomics,+bulk-memory,+mutable-globals) genuinely runs them, both under wasmtime and in a real browser. Getting it working in-browser meant hand-rolling the WASI threads proposal's ABI in JavaScript — no wasm-bindgen — since a WASM program's execution is one continuous synchronous call and the calling thread only ever yields at real blocking waits (Atomics.wait, compiled from this same fiber design's Condvar::wait()), which don't drain the JS microtask queue. That rules out spawning a new Worker synchronously from inside a blocking call (verified directly: it deadlocks), so the shim pre-warms a fixed, generous pool of idle workers (64) before the program starts and hands off to one already booted, rather than growing the pool on demand — a real, load-bearing architectural constraint, not an implementation shortcut: a genuinely unbounded synchronous burst of fiber creation can still exceed the pool (verified with a 20-fiber burst test), though realistic PatLang fiber/budgeted usage comfortably fits under it. See the fiber demo page for a real "run in browser" button using this, and Capabilities & Honest Limitations for the current split between the two WASM targets.
Self-Hosting
The lexer, parser, lowerer, and code generator (self_hosting/lib/{lexer,parser,lower,codegen}.patlang) are themselves written in PatLang, compiled once via rustc into patc1.exe, and then used to compile all further ordinary PatLang programs, including further versions of themselves. patc1.exe is fixpoint-verified: recompiling its own source through itself produces byte-identical Rust output. This isn't a novelty demo — it's the actual, current compilation path for everyday PatLang development (see Idioms & Patterns for the self-hosted-mirror-with-parity-test pattern this relies on).
Reach for this when you're extending the compiler itself, or when you simply want to compile a program — patc1.exe is the ordinary tool for that, not a special case.
See also
- Paradigm Gallery: Singles and Pairs — 23 short, verified-running examples, one per paradigm plus every pair combined.
- Grammar & Syntax — exact syntax for everything above.
- Standard Library & Host Function Reference — the full host-function surface each paradigm is built on.
- Use Cases — larger programs combining several paradigms.
- PatLang Real OS Threads, PatLang Fibers, and Robots: cross-thread signaling — live demos of the concurrency section above.