PatLang Best Practices

Most of this comes from the same place the journey page's lessons do: real code written this session, real bugs found while writing it, and a few habits that turned out to matter more than expected once programs got past toy-demo size. This isn't a style guide imposed from outside PatLang — it's what actually worked, and what actually broke, while building the standard library, the self-hosted compiler, and the build daemon exemplar.

Prefer immutability — reach for let mut deliberately, not by default

Bind with plain let unless a name genuinely needs to be reassigned within its own scope, and treat needing let mut as a small signal worth noticing, not a routine choice. Two concrete reasons this matters more in PatLang than it might elsewhere:

  • Lists and strings are value semantics by default — passing or reading one conceptually copies it. Code that mutates in a loop via reassignment (let xs = list_push(xs, item)) is honest about that cost; code that looks like in-place mutation but is actually rebuilding the whole structure each time is exactly the shape of bug that made the self-hosted compiler's own bootstrap take 3m51s before a fix, 45s after. If you notice yourself reassigning the same large list or string in a tight loop, that's the moment to reach for the handle-based builders (vec_new/vec_push, sb_new/sb_push) instead — see Handle-based vs list-based collections.
  • PatLang has no try/catch (see the gotchas section below). Code built around reassigning state as things happen is harder to reason about when any single step can be fatal to the whole program — code built around binding once, deriving the next value, and rebinding is easier to audit for "what state exists at the point this call might abort."

Use require/ensure routinely, not just on "important" functions

Contracts are cheap to write and compose with everything else in the language — a successful check asserts a contract_holds fact automatically, so routine preconditions and postconditions become derivable data usable as a rule-body conjunct or a GOAP precondition later, not just a runtime check that disappears once it passes:

make a function called divide takes a, b returns q
  require b != 0
  let q = a / b
  ensure q * b == a
  return q
end

The instinct to reserve contracts for "the important functions" undersells them — because a passing check becomes a fact, contracts written throughout a codebase quietly build up a queryable record of what's been verified, for free. See the Design by Contract section and the contracts demo.

Instrument with events and self-timing while developing, not just when something's already slow

PatLang has no separate profiler or tracing framework to bolt on — now_ms() and emit(...) are the same primitives your program already uses, so instrumenting a function is a two-line addition, not a new dependency:

let t0 = now_ms()
let result = expensive_step(input)
emit("step_finished", "expensive_step:" + (now_ms() - t0))

Reach for this earlier than feels necessary. The build daemon exemplar's whole design leans on this: every build records its own real duration to a small history file that survives between runs, which then feeds both live dashboard output and genuinely cost-aware GOAP planning on the next run — see the build daemon exemplar. A program that already emits started/finished events for its own significant steps costs almost nothing extra and gives you both a live dashboard and a persisted performance history for later, essentially for free.

Define a syntax DSL when the domain's natural vocabulary genuinely differs from general-purpose code — not as a default

syntax NAME { ... } blocks are a real, working runtime-extensible grammar mechanism, not a toy — see the router DSL demo and the dynamic syntax demo. The good-practice line here cuts both ways: reach for a DSL when a problem's natural notation is genuinely awkward to express as ordinary function calls (route tables, grammar-shaped configuration, anything a domain expert would recognise faster in its own notation than in PatLang's), but don't reach for one just because you can. A DSL is a second surface syntax someone reading the code has to learn, layered on top of the base grammar — worth it when the vocabulary mismatch is real, a net loss in readability when it's a thin wrapper around what a few well-named functions would already say plainly.

Give every long-running program the same small set of default signals

If a program is going to run for more than a few seconds unattended — a daemon, a dev server, a watch loop — build in self_hosting/lib/signals.patlang support from the start, and standardise on the same handful of signal names across your own programs so they're predictable to operate:

  • status (request/response, via signal_query) — report live state back: what's running, how long it's been running, an ETA if one's derivable. See the "instrument with events and self-timing" practice above — a program already emitting timing events has everything status needs to answer honestly.
  • quit (fire-and-forget, via signal_send) — a graceful shutdown path, so nothing needs Stop-Process -Force or the equivalent to stop cleanly.
  • A domain-specific action verb (the build daemon's is rebuild) — whatever the one thing an operator would most want to trigger on demand without restarting the process.

This costs very little once written once — self_hosting/examples/build_daemon_demo.patlang and self_hosting/examples/signals_demo.patlang are both under 90 lines including the signal-handling — and it means every long-running PatLang program you write behaves the same way from the outside, rather than each one needing its own bespoke "how do I stop this" answer. See the signals demo and the Paradigms Guide's Signals section.

Give every queue writer its own topic — don't share one across concurrent producers

The durable message queue (self_hosting/lib/queue.patlang) does a whole-file read-modify-write on every queue_publish/queue_ack call, and PatLang has no file-locking primitive — two writers publishing to the same topic at the same instant can race and clobber each other's write. The idiomatic fix isn't to add coordination (there's nothing to add it with); it's to avoid the race by construction:

let topic = queue_writer_topic("orders", worker_id)   # e.g. "orders__worker_2"
queue_attempt(topic, payload, risky_action)

Every writer gets a topic name derived from its own identity, so no two writers ever touch the same file — full stop, not "usually fine." A reader that needs the combined stream reads across that whole family with queue_pending_multi/queue_consume_multi rather than one consumer contending with producers over a single shared file:

let topics = [queue_writer_topic("orders", "worker_1"), queue_writer_topic("orders", "worker_2")]
let pending = queue_pending_multi(topics)     # [topic, msg_id] pairs across every writer
let next = queue_consume_multi(topics)        # [topic, payload] — first topic in the list with anything pending

This is the general idiom, not a queue-specific trick: whenever multiple independent producers write to durable state PatLang gives you no locking over, give each one its own namespaced slice of that state and let the reader fan out across the slices, rather than trying to serialize writers onto one shared resource the language has no way to help you serialize safely. See the queue recovery demo for this pattern combined with a genuine crash and a genuine recovery pass across several workers.

Reach for the declarative engines before hand-writing search or graph logic

If you catch yourself writing a recursive dependency walk, a "does X eventually satisfy Y" check, or a hand-rolled search for the cheapest way to reach some goal state, stop and ask whether rule_add/solve (real backward chaining) or action_add/plan (real GOAP) already is that function:

rule_add("dep", ["a", "b"], [])
rule_add("dep", ["b", "c"], [])
rule_add("unchanged", ["c"], [])
rule_add("buildable", ["X"], [["unchanged", ["X"]]])
rule_add("buildable", ["X"], [["dep", ["X", "Y"]], ["buildable", ["Y"]]])
solve("buildable", ["a"])

This isn't just shorter — it's a genuinely different, and usually more correct, way to solve the problem: backtracking and cost-optimal search are exactly the hard parts of hand-written graph code to get right, and both engines already handle them. See the Logic / Goal-Oriented section and the goal-oriented demo. The real declarative surface syntax (rule Head(args) :- Body.) reads even closer to the underlying logic than the function-call form above — see the Grammar reference.

Gotchas worth coding around, not just knowing about

Three things discovered the hard way this session, each worth an active habit rather than just awareness:

  • Don't reference a top-level let constant from inside a function. This silently fails to resolve under `patc1.exe` generally, and under plain `--ir-run` specifically for any function invoked via parallel_map — a real, currently-unfixed language gap (see Capabilities & Honest Limitations). The working habit: make anything that looks like a constant a small getter function returning a fresh literal, or pass it as an explicit parameter, rather than closing over an outer let. Every library built this session (signals.patlang, build_daemon.patlang, includes.patlang) follows this rule throughout.
  • There is no try/catch, anywhere. A failing host call — a bad file path, a refused connection, binding an already-used port — is fatal to the whole program. Structure code to avoid the failure condition in the first place (check file_exists before read_file; use the non-panicking tcp_try_listen instead of plain tcp_listen when "is something already there" is a real possibility) rather than writing code that assumes it can recover after the fact. For genuinely I/O-risky steps where you want recoverability rather than just avoidance, see the Durable Message Queue section's queue_attempt — it doesn't make the failure non-fatal, but it leaves a durable, recoverable checkpoint for the next run to find.
  • rule_add has no retract. A fact registered once stays registered for the life of the process. This is invisible in short-lived scripts and a real correctness trap in anything long-running that calls rule_add/action_add more than once — the build daemon exemplar hit exactly this (a stale built fact from an earlier cycle satisfying a later cycle's goal for a target that was actually stale again) and fixed it by tagging cycle-local predicate names uniquely per cycle. If a program registers facts more than once over its lifetime, plan for this from the start rather than after the first confusing wrong answer.

See also

Idioms & Patterns for more internal-implementation-level patterns found building PatLang itself. Capabilities & Honest Limitations for the full, current list of gaps. The Journey of Building PatLang for the fuller story several of these practices come out of. If you're coming from another language, the for Python, for Java, for C, and for Ruby pages cover the syntax-level gotchas most likely to trip you up first.