What sets tramaj apart

On Fri, 11 Sep 2026, by @lucasdicioccio, 702 words, 3 code snippets, 5 links, 1images.

What sets tramaj apart

Plenty of languages already do pieces of what tramaj does. JSX makes documents a value inside an expression language. Dhall and Nickel are typed, functional, importable configuration languages. Jsonnet generates structured data from a small functional core. CUE builds a schema out of constraints. Starlark restricts a language deliberately so tooling can reason about it. None of that is new by itself.

What’s distinctive about tramaj is a specific combination: a document is an ordinary value in a small functional language, and the language is restricted in exactly the places needed to make a program’s external interface — what it imports, what context it reads, what actions it can emit — recoverable by walking the AST, without evaluating it. Most template systems optimize for how easily they generate output. tramaj optimizes for how much a host can know about a program before it runs.

A document is just a value

There’s one expression grammar. A document (an Element or Fragment) is a Value, exactly like a string or a number — it can be bound, passed to a lambda, returned from a function, collected by map, or handed to import. There’s no separate template phase sitting on top of a host language, the way JSX sits on top of JavaScript.

@row = import("row", {kind: ctx(row-kind)})
.ul(
  map($ctx.items, (item) => $row({"title": $item.title}).rendered))

The array map returns is already a sequence of document children — “build a document” and “compute a value” are the same activity. See Reference for the full grammar.

ctx(...) is a declared hole, not just a read

$ctx.title and ctx(title) are equivalent at runtime. They are not equivalent to static analysis. $ctx.title is a dynamic read that could, in principle, be buried behind arbitrary computation. ctx(title) is a literal path in the syntax — the author is declaring, in a form a tool can see without running anything, “this is a hole the caller is expected to fill.”

That costs something: a ctx(...) path can’t be computed. It has to be written down. tramaj takes that trade deliberately, because it’s what turns “what does this template depend on” into a question a static pass can answer over an unmodified module — not just the top-level program, but a library sitting behind an import.

Imports accumulate parameters before they run

@panel = import("panel", {})
@half = $panel({"name": "web"})
$half({"replicas": 2}).rendered

An import isn’t “load this module” — it’s a partially applied program. Each call supplies more parameters; nothing executes until .rendered or .vals is actually read. That gives a wired-once, specialized-per-item pattern for free:

@row = import("row", {kind: ctx(row-kind)})
map($ctx.items, (item) => $row({"title": $item.title}).rendered)

Because contextReads and unsuppliedParams are static analyses (see Properties), a host can diff what a library expects against what an import supplies — title supplied, description and id still open — without running the library. That’s close to an implicit interface for modules, without introducing a conventional module type system.

Actions are a statically discoverable vocabulary, not an escape hatch

action("on-click", "deploy", {"id": $ctx.id}) attaches an opaque, structured action to a node. The event and the key are literal strings; only the payload can be computed. adapt-actions(node, prefix("deployment:")) can rewrite a subtree’s action keys under a fixed prefix, and the optional transform hook is allowed to touch the event and payload but never the key.

That restriction exists for one reason: so staticActionKeys(program) stays answerable by walking the AST. A host embedding an untrusted or model-generated template can ask “what could this possibly do to my page or my system” — the full set of action keys it might ever emit — before a single line of it runs. See Use cases for what that buys in practice, and Properties for the determinism and portability guarantees underneath it.

The shape this adds up to

Put the three together and a tramaj program has a discoverable interface even though it’s an executable program:

The interface of a program, recovered by walking its AST: context holes, imports and unsupplied params, action keys, all before any evaluation

None of documents-as-values, static analysis, or restricted languages is new on its own — JSX, Dhall, CUE, and Starlark each stake out real territory here, and tramaj doesn’t compete with Dhall’s type system or CUE’s unification. What’s specific to tramaj is treating the combination — document composition + deferred, parameterized imports + a statically recoverable dependency and action vocabulary — as the actual point, instead of a byproduct of “yet another way to generate markup.” That’s why Properties calls out cheap static analysis as an invariant, not a feature: it’s the thing the rest of the design exists to protect.