Design document. Captures the staging model, the compile/runtime bridge, the reflection tier, and the provenance model that makes generated code reviewable in Typist.

1. Motivation

Typical is meant to compile to native code while staying fully sound and always-compiling. Separately, Typist is meant to let AI generate large bodies of code that a human can still understand and direct rather than accept as a black box.

Those two goals meet at a single feature: compile-time code that can construct and mutate the program itself, with the compiler recording enough about that construction that a reviewer can trace any piece of generated code back to exactly what produced it — not by static analysis after the fact, but because the compiler watched it happen.

This is the same idea as the "generation chain" model (codegen as a sequence of reviewable stages, each a projection of the last) applied specifically to Typical's own compile/runtime boundary.

2. The staging model

Staging isn't a ladder of trust tiers — it's a small set of rules about identifiers and where they're declared. Any statement can be pushed onto the compile side by leading it with ! followed by a space. This doesn't change its semantics; it asserts that everything it depends on is statically known, and evaluates it early. The marking applies to the whole statement: because of how the parser scopes what follows, ! placed before a function pulls that function's entire body — including its opening and closing parentheses — along with it into the compile-time statement.

2.1 Non-staged constructs

Some constructs never have a runtime existence at all: one of, type aliases, and similar declarative forms. These require no ! marking, because their meaning is definitionally independent of anything only known at runtime. There is no "compile-time version" of one of — there is only one version, and the staging rules below simply don't engage for them.

2.2 One namespace, one declaration site

Identifiers on the compile side and identifiers on the runtime side share a single namespace. A name may be declared on only one side; declaring it again on the other side in the same scope is a redeclaration error, not a shadow. Two consequences follow:

This is also why no special "read a compile-time value" syntax is needed. Since a name resolves to exactly one binding regardless of which side declared it, there's nothing to disambiguate at the use site. (It also sidesteps an actual syntax collision: ! already means boolean negation, so a hypothetical !day marker at a use site would be genuinely ambiguous with "negate the boolean day.")

foo() (
	! day = Day.tuesday

	! if (day > Day.monday) (
		console.log("Looks like day was after monday at compile time...")
	)
)

Day is one of (
	monday
	tuesday
	wednesday
)

2.3 Reading across the boundary is asymmetric

2.4 Calling into unmarked runtime code requires referential transparency

Compile-time code may call an ordinary, unmarked runtime function only if that function is referentially transparent: no reads of mutable ambient state, no I/O, no clock, no randomness — output determined entirely by arguments. This is checked transitively, the same way effect systems check that a function's effects are a subset of what's granted.

This isn't a separate category of function — any ordinary function is just a function. The restriction lives at the call site, on the compile-time side, as the price of proving it's safe to invoke early. Purity is what makes a function usable from both sides without being written twice; it deliberately doesn't extend to anything that touches disk or network — that kind of effectful generation belongs to a different stage entirely (see the companion generation-chain document).

2.5 !-declared functions have no runtime existence

This isn't a withheld permission — there's simply nothing left to call. By the time codegen finishes, a !-declared function has already been executed and is gone; there's no artifact for runtime code to link against. Asking whether runtime code can call it is like asking whether generated code can call the macro that generated it.

2.6 Compiler-API effects are ambient, not a gated tier

Inside compile-time code, calls like Space.append and constructor calls that produce a code-DOM representation (Section 4) are simply things compile-time code can do — the same way runtime code can do I/O without invoking a special keyword. There's no separate, more-trusted syntactic form for "reflection code" versus ordinary compile-time code; what makes arbitrary construction and mutation safe to expose is uniform across all compile-time code, and comes from interpreter tracing (Section 5), not from gating a subset of it behind different syntax.

3. Types as compile-time values

Reflection code needs to pass types around as ordinary values (e.g. Field("description", string)). Typical's generics already use a types-as-values syntax, so this falls out of the existing design rather than requiring a new mechanism — a type reference is just a value like any other argument.

4. Spaces as mutable collections

Spaces (Typical's term for namespaces) become mutable, appendable collections when accessed from compile-time code. A space starts empty (or with its ordinary declared contents) and compile-time code can push newly constructed definitions into it.

RuntimeObject (
	name is string
	value is string
)

space App (

)

// ! start gives you a compile-time entry point
! start (
	ro = 🏗️ RuntimeObject()
	ro.fields.push(Field("description", string))
	App.append(ro)
)

Stage-polymorphic construction

Typical has no new keyword — constructing something is just calling it, e.g. RuntimeObject(). What that call produces depends on which side calls it:

The same syntax produces a different shape of result depending on stage — a bigger asymmetry than ordinary staging, where !-evaluation usually just means "the same semantics, resolved early." In a plain text editor this ambiguity would be a real hazard (code reading ro.fields.push(...) could be misread as mutating an object's data rather than a class's shape). Typist's 🏗️ marker resolves this at the point of construction, so the ambiguity never reaches the reader in the first place — it's a rendering concern, not a type-system concern.

Sealing

A space stops accepting appends at the end of its ! start() block, once there are no outstanding callbacks left to resolve. A generation timeout guards against run-on generation (e.g. a callback chain that never terminates) so sealing is always reached. After sealing, a space's contents are fixed and ordinary static typing proceeds against them as if they'd been declared directly.

5. Provenance via interpretation, not static analysis

Because ! start blocks and other compile-time code are run by an interpreter rather than compiled opaquely, the compiler observes every step of construction as it happens: every 🏗️-marked constructor call, every mutation (ro.fields.push(...)), every append. This gives a complete, literal chain of history from the compile-time code that initiated a value to wherever it landed in the final program — recorded as the run happens, not reconstructed afterward.

This is a materially different (and easier) problem than the one the generation-chain document originally posed. That document assumed provenance would have to be static — given a piece of generated output, work backward through the generator's source to find the rule that must have emitted it. That's tractable for template-shaped generation but breaks down for arbitrary reflection, where there's no clean rule-to-output mapping to point at.

Interpreted execution sidesteps the inverse problem entirely: the compiler already knows what produced a value, because it watched the value get produced. This is what makes unrestricted compile-time construction compatible with Typist's fault-localization goals — the power and the traceability are no longer in tension, because traceability doesn't depend on the generation being structured. It depends on the compiler being the one executing it, step by step, with a record kept.

This also means the trace shown to a reviewer is a property of one run, not a static property of the generator — if a ! start block branches on something that can vary between runs, the chain-of-history view is a debugging/replay artifact tied to that run, not something Typist can present without having executed the generator at least once.

6. Why two different restrictions solve two different problems

It's worth keeping the referential-transparency restriction (2.4) and interpreter tracing (Section 5) conceptually separate, because they answer different questions:

Compile-time code isn't required to be referentially transparent itself — it's allowed to do far more than calling into pure runtime functions permits. What makes that safe to expose in the editor isn't a purity restriction, it's that every run is fully observed.

8. Open questions

  1. Hole identity for template-generating functions. A related but distinct pattern (a class definition with bare ! holes standing in for constructor parameters, producing a portable, fillable class value) needs named holes if the same hole is meant to be reused at multiple positions (e.g. a field name reused in a method signature). A bare, unnamed ! only works cleanly if each occurrence is guaranteed to be a distinct parameter.
  2. Typed holes vs. raw AST. Whether the "class with holes" produced by such a function should be a first-class typed value (a declared hole signature, checked before it's filled) rather than untyped syntax — the former stays consistent with Typical's "no escape hatches" goal; the latter risks the stringly-typed pitfalls of text-based macro systems.
  3. Nested compile-time calls. If compile-time code's output can itself contain further compile-time constructor or reflection calls, an expansion order needs to be chosen (single pass, fixpoint, explicit stage numbering) or ordering bugs of the kind proc-macro systems run into become possible.
  4. Multiple entry points into one space. If more than one ! start (or other compile-time entry point) can append into the same space, sealing has to wait for all contributors, and an ordering/determinism story is needed across them — the same class of problem as nested expansion, one level up.
  5. Compiler-side imports. Should authors be able to ! import code on the compiler side like they can on the runtime side?