Tramaj — Language Reference

On Fri, 02 Oct 2026, by @lucasdicioccio, 2835 words, 15 code snippets, 4 links, 0images.

Generated from specs/reference.md — the repository is the canonical source, and may be ahead of this page.

Tramaj — Language Reference

This is the reference for the language as implemented. Where the earlier drafts disagree with each other or with this document, this document wins; decisions.md records which reading of those drafts was taken and why, and the drafts themselves are in archive/. node-json.md is normative for the output wire format and is not repeated in full here.

Two implementations are held to this document: tramaj/ (PureScript) and tramaj-hs/ (Haskell). Anything below marked implementation-defined is where they are permitted to differ; everything else they must not.


1. Shape of the language

Tramaj is expression-oriented. There is one expression language, and documents are values: an element or a fragment is an ordinary expression result that can be bound, passed to a lambda, returned from one, or stored in an array.

  surface syntax
        │  parse + desugar
        ▼
    core AST  ──────────────┐
        │  evaluate         │  analyse (no evaluation)
        ▼                   ▼
      Value           imports / action keys / context holes
        │
        └── Node ──► normative JSON ──► host fold (HTML, YAML, HCL, UI, …)

The core AST is deliberately smaller than the surface. Conveniences are lowered into it (§8) rather than given constructors of their own.


2. Core AST

Program
  = DocumentProgram Expr
  | ExpressionProgram Expr

Expr
  = Path         root: String, fields: List<String>
  | FieldAccess  target: Expr, fields: List<String>
  | Call         function: Expr, arguments: List<Expr>
  | Lambda       parameters: List<String>, body: Expr
  | Let          name: String, value: Expr, body: Expr
  | StringLit    String
  | NumberLit    Number
  | BoolLit      Boolean
  | NullLit
  | ArrayLit     elements: List<Expr>
  | ObjectLit    fields: List<(String, Expr)>
  | Element      tag: String, attributes: List<Attribute>,
                 value: Expr, children: List<Expr>
  | Fragment     children: List<Expr>
  | Branch       condition: Expr, then: Expr, else: Expr
  | Map          collection: Expr, function: Expr
  | Filter       collection: Expr, function: Expr
  | Scan         collection: Expr, initial: Expr, function: Expr
  | Fold         collection: Expr, initial: Expr, function: Expr
  | Concat       left: Expr, right: Expr
  | Import       name: String, parameters: List<(String, ParamValue)>
  | AdaptActions target: Expr, adaptation: ActionAdaptation,
                 function: Optional<Expr>

ParamValue
  = PExpr        Expr                 -- any expression, evaluated at the import
  | PFromContext path: List<String>   -- read from the importing program's $ctx

Attribute
  = Attr         name: String, value: Expr
  | ActionAttr   event: String, key: String, payload: Expr

ActionAdaptation
  = Identity
  | Prefix       String

Every static position is a String, not an Expr: an import’s name, an action’s event and key, an adaptation’s prefix, a ctx(...) parameter’s path. That is what makes §9’s analyses possible, and the grammar refuses anything else in those positions — the restriction is enforced at parse time, not checked later at evaluation time.

There is no partial-import constructor, and partiality is not visible in the syntax at all: an import is unsaturated when the library reads a parameter nobody has supplied yet, which is a fact about two programs, not about this one (§7).


3. Values

Value
  = Null | Boolean | Number | String
  | Array   List<Value>
  | Object  Map<String, Value>
  | Node                        -- a document
  | Closure                     -- a lambda, with its captured environment
  | Builtin                     -- a builtin, referenced but not yet called
  | ImportResult                -- {rendered, vals}, once the library has run
  | Import                      -- an import wired up but not yet run

Arrays and objects hold Values, not JSON — so a document can travel inside a structure. That is what makes the JSX-children pattern work without a separate “template value” category.

JSON is required only at the boundaries where a document would be meaningless: an attribute value, an action payload, an element’s value slot, and an expression program’s result. A closure, a builtin, an import result, an import that has not run, or a document reaching one of those positions is an error, not a silent serialization.


4. Node (output)

Node
  = Text      value: Value,  annotations: Annotations
  | Element   tag: String, attributes: List<NodeAttribute>,
              value: Value, children: List<Node>, annotations: Annotations
  | Fragment  children: List<Node>, annotations: Annotations

NodeAttribute
  = Attribute name: String, value: Value
  | Action    event: String, key: String, payload: Value

Annotations = Map<String, JSON>
  • The text leaf carries a Value, so a scalar child keeps its type: .td($ctx.count) yields the number 3, not "3". How that becomes characters is the host’s decision.
  • Element carries a value slot alongside its children, for targets that attach a body value to a tagged node. It is null unless set.
  • attributes is an ordered list, so an element may carry any number of attributes and actions, and duplicate names are preserved, not merged.
  • Annotations have no core meaning. They are the extension point for types, domains, provenance and host metadata, and every transformation must carry them through unchanged.

Serialization — including the strict decoding rules — is specified in node-json.md.


5. Surface syntax

Three leader characters: . builds a document, $ reads a name, @ defines one.

@count=cardinality($ctx.items)          -- bindings, one per line
@panel=(title, kids) => .section(.h2($title), $kids)

.div(class: "panel",                    -- attribute position first
     action("on-click", "save", {"id": $ctx.id}),
     value($ctx.raw),
     .h1("Title"),                      -- then children
     .( .p("a"), .p("b") ),             -- a fragment: tagless element
     map($ctx.items, (i) => .li($i.name)),
     branch(.p("none"), gt($count, 0), .p("some")))
FormMeaning
$name, $name.a.bread a binding, then static field access
name(args), $name(args)a call; the two spellings are identical
$lib.vals.fn(1)the callee may be a dotted path
f(x).renderedfield access on a call's result
"text", "n: `$x`"string literal, backtick interpolation
123, -1.5, 1e5, 1_000_000number literal; see Number literals below
true, false, nullkeyword literals
[a, b]array literal
{"k": v}, {k: v}, {foo}object literal; keys may be bare; {foo} is shorthand for {"foo": $foo}
(x, y) => exprlambda
({a, b: c}, y) => exprlambda with an object pattern parameter; see Binding patterns in §8
@{a, b: c}=exprbinding with an object pattern; see §8
(expr)grouping
-- texta comment, to the end of the line
a <> bconcat — the only infix operator, left-associative, lowest precedence
.tag(...)element
.(...)fragment
value(expr)element value slot; at most one, attribute position
action("event", "key", payload)action; attribute position, any number
map/filter(coll, fn), scan/fold(coll, init, fn)array primitives
branch(fallback, p1, v1, …)conditional
import("name", {k: expr, j: ctx(path)})import; a parameter may also be left out and supplied later
adapt-actions(node, prefix("ns:") \| identity [, fn])action adaptation

Names — bindings, path segments, tags, bare keys — start with a letter and may contain letters, digits, _ and internal -. Internal is enforced, not merely advised: a hyphen belongs to the name only when another name character follows it, so a name never ends in one and $x-- note reads as x followed by a comment. Whitespace is insignificant except as a separator, with one exception: a field-access . must directly follow the ) it applies to, so f() on one line and .div(...) on the next are two separate things.

Comments. -- begins a comment that runs to the end of the line. There is no block form and no nesting, so a comment cannot be left unterminated and a second -- inside one is simply more comment. A comment is legal wherever a space is — above the bindings, trailing one, between an element’s arguments, after the root — and is discarded together with the whitespace around it, so it reaches neither the core AST nor §9’s analyses. Inside a string literal -- is ordinary text: a string body is read character by character and never passes through this rule.

Being whitespace, a comment does not join the lines it sits between: f() -- note followed by .div(...) on the next line is two separate things, exactly as the uncommented version is.

Number literals.

number = ["-"] digits ["." digits] [("e" | "E") ["+" | "-"] digits]
digits = digit { ["_"] digit }
  • The - is part of the literal, not an operator (there is no arithmetic, §11), so it must touch the first digit: - 1 is a parse error, and so is a leading +. --1 is a comment, like any other --.
  • A _ separates digits for readability and means nothing: 1_000_000 is 1000000. It is allowed only between two digits — 1_, 1__0, 1_.5, 1._5 and 1e_5 are all parse errors — and may appear in the integer, fraction and exponent parts alike (0.000_001, 1e1_0).
  • Both parts around a . need at least one digit: .5 and 5. are not numbers.
  • The literal denotes the double nearest to its decimal value (round to nearest, ties to even). A literal too large for a double (1e400) is a parse error rather than an infinity; one too small rounds to zero.
  • There is no negative zero: a literal that denotes zero is 0 whatever its sign, so -0 and -1e-400 both evaluate to 0.

Attribute position before children. Attributes, action(...) and value(...) must all precede any child; a child first is a parse error.

Static positions reject interpolation. A backtick inside an import name, action event/key, adaptation prefix or object key is a parse error rather than a literal backtick — someone writing import("lib-`$x`") means interpolation, and that position cannot be computed.

A malformed special form is a parse error. Name recognition backtracks; the shape does not. action("on-click", $computed, {}) fails to parse rather than quietly becoming a call to an unbound function named action.

Program kind

A program is bindings followed by a root expression. parseProgram reports DocumentProgram if the root is written as a document (.tag(...) or .(...)) and ExpressionProgram otherwise. That is a syntactic classification; what evaluation actually produces is decided at runtime (§6).


6. Evaluation

Evaluation is eager and deterministic, with exactly one exception (Branch). Aside from binding order, no evaluation order is prescribed.

Bindings. @name=expr lines lower to nested Lets, so a binding may reference $ctx and any earlier binding, never a later one. A binding’s value is inserted only after it is evaluated, which is why there is no recursion: a lambda cannot call itself by its own binding name.

Closures capture the environment where the lambda is evaluated. Builtins are ordinary values in the initial environment, so one can be passed by reference: map($ctx.flags, $not).

Branch evaluates its condition, then only the selected arm. Errors in an unreached arm never surface. This holds in every position — there is no separate eager form. The condition must be a boolean.

Elements evaluate attributes, then the value slot, then children, in source order.

Children coercion is the one rule the document side adds:

child valuecontributes
a documentitself, as one child
an arrayeach element, recursively — this is how map(...) repeats children
anything else JSON-ableone text node carrying that value unconverted
a closure / builtin / import result / unrun importan error

So an array in child position is a sibling sequence, not one value. An array wanted as data belongs in an attribute or the value slot.

Concat (a <> b) is a monoid over three types, with no coercion:

String × String → String      ""   identity
Array  × Array  → Array       []
Object × Object → Object      {}   right-biased on key collision

Mixed types are an error. Object key order is not semantically significant.

str, and string interpolation. "n: `$x`" lowers to Concat over str(...), so str decides what interpolation puts in the output. Its rendering is normative:

valuerenders as
stringitself, raw
null""
booleantrue / false
numberexactly as ECMAScript's Number::toString — 3, 1.5, 0.05, 100000000000, 1e+21, 1e-7
array / objectcompact JSON, keys sorted, numbers by the same rule, strings JSON-quoted

Keys are sorted because key order is not semantically significant, so it must not be observable here either. Both implementations must produce these character for character.


7. Imports

import("deployment", {
  name:     "web",              -- (a) an expression, evaluated here
  replicas: ctx(spec.replicas)  -- (b) this program's $ctx.spec.replicas
})                              -- (c) anything not listed: supplied later

Three ways to supply a parameter, and the third is not writing it down.

(a) Any expression. It is evaluated where the import is written, like every other argument in the language.

(b) ctx(path) reads the importing program’s own context, also where the import is written. It means exactly what $ctx.spec.replicas means, and a path the context lacks is the same PathNotFound — the two forms are interchangeable at runtime and the evaluator makes no distinction between them.

The distinction is entirely static. ctx(path) puts the path in a static position in the AST, where §9’s contextHoles can enumerate it directly; the same read spelled $ctx.spec.replicas is an ordinary path buried in an arbitrary expression, indistinguishable from every other read. Writing ctx(...) is how an author says this is a hole — count it, and it costs the ability to compute the value, which is the point.

(c) A parameter the import does not mention is supplied later, by calling the import with an object of more parameters. Merging is right-biased, like <> on objects, so a later call overrides an earlier value for the same name.

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

An import runs when a field is read off it — .rendered or .vals — never where it is written. Everything before that is accumulation, which is what lets one wired-up import serve a whole map, each iteration adding its own parameter:

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

A run import exposes .rendered (whatever its root evaluated to — a document or an ordinary value) and .vals (its own top-level bindings). Using the import itself where a value is expected is an error: it has not run, so it has nothing to serialize.

A library is evaluated against its parameters as its own fresh $ctx — a library’s $ctx is its parameter object, which is why a library reads $ctx.title for the parameter title, and why a ctx(...) hole inside a library is a hole in what its importer must pass. Import names are resolved by a host-supplied table; where a library came from is not the language’s business. Re-entering a library already being evaluated is reported as a cycle, not run.

Nothing checks that the parameters are enough before running a library, because nothing in the import knows what enough would be. A library that reads a path nobody supplied fails with its own PathNotFound, wrapped in an InLibrary naming the library that raised it:

import("panel", {"name": "web"}).rendered
  -- InLibrary "panel" (PathNotFound ["ctx", "replicas"])

The static counterpart is §9’s unsuppliedParams, which answers the same question without running anything.

Known limitation: an import or adapt-actions result cannot be called directly — import("x", {...})({...}) is a parse error. Bind it first, as the examples above do. Only field access is available as a postfix on a call.


8. Desugaring

The parser lowers each of these; none has a core constructor.

surfacecore
@a=1 @b=$a rootLet "a" 1 (Let "b" $a root)
"n: + `$x` + !"Concat (Concat "n: " (str $x)) "!"
"a\nb"one StringLit with a real newline
{foo, bar: 1}ObjectLit [("foo", Path foo), ("bar", 1)]
branch(f, p1, v1, p2, v2)Branch p1 v1 (Branch p2 v2 f)
a <> b <> cConcat (Concat a b) c
.div()Element "div" [] NullLit []
@{a, b: c}=$x.yLet "a" $x.y.a (Let "c" $x.y.b …)
({a}, n) => eLambda ["#arg0", "n"] (Let "a" $#arg0.a e)

Escape sequences:

\n  \t  \r  \\  \"  \`  \0  \u{1F600}

\u{...} is braced, so an astral code point needs no surrogate pair.

Binding patterns. An @ binding or a lambda parameter may be an object pattern instead of a name (decisions §17):

pattern ::= name | "{" field { "," field } [","] "}"
field   ::= name | name ":" pattern

{title, kind: k, meta: {owner}} binds title, k (the field kind) and owner, reading each field the way $x.title would: a missing field is PathNotFound, a non-object is TypeMismatch, and extra fields are ignored. A path source is read directly, so @{a} = $ctx.item is @a=$ctx.item.a; any other source (a call, a literal) is evaluated once. A pattern parameter takes one position, so arity is unchanged. Bindings are made left to right.

These are parse errors: a name bound twice in one pattern, a default ({a = 1}), a rest element ({...r}), an array pattern (@[a, b]), an empty pattern ({}) and an annotation on a pattern (@{a} : T = e). The parser invents hidden names for the lowering (they start with #, which no surface name can); they never appear as a symbol’s "binding" or in a library’s .vals.

Deferred: array patterns, defaults and rest (see decisions §17 for why).


9. Static analysis

All of these answer from the AST alone — no context, no library evaluation, no host code. The three the laws ask for each have a deep variant that follows imports through a library table and cuts cycles.

functionanswers
staticImportNames / transitiveImportNameswhich libraries this program depends on
staticActionKeys / deepActionKeyswhich action keys it can emit
contextHoles / deepContextHoleswhich context paths it declares as holes with ctx(...)
contextReadsevery path it reads from its own context, $ctx.a and ctx(a) alike
unsuppliedParamsper import, the paths its library reads that the import does not supply

contextReads of a library is the shape of the parameter object it expects, since a library’s context is its parameters. unsuppliedParams is that set minus what each import supplies — the parameters still to be saturated by a later call (§7), computed without running anything. It attributes a read to a parameter by the read’s first segment, and stops at the library’s own reads rather than following that library’s imports, whose parameters it supplies itself.

staticActionKeys applies adaptation rather than ignoring it, using the same function the evaluator does: adapt-actions(x, prefix("user:")) over {save, delete} yields {user:save, user:delete}. That is the payoff of restricting adaptation to identity-or-prefix.

These are over-approximations: keys under a Branch arm that a given context will never select are still reported, and so is a parameter a library reads only in such an arm. A name that is imported but missing from the table is reported too — an unresolvable dependency is what a caller wants to hear about — though it contributes no unsupplied parameters, since what it needs is unknowable rather than nothing.


10. Actions and adaptation

action("on-click", "deploy", {"deployment": $ctx.name})

The event and key are literals; only the payload is computed. The language assigns meaning to neither — the event vocabulary is the host’s, and what is fixed is the position, not the words allowed in it.

adapt-actions(node, prefix("deployment:"))
adapt-actions(node, identity)
adapt-actions(node, prefix("ns:"), fn)

Prefixes every action key in the subtree, reaching through imported programs and supplied fragments. Adaptations compose the obvious way — a: then b: gives b:a:key — with no “already adapted” state. Applied to an import that has not run, the adaptation is queued and runs on its result.

The optional fn sees each already-prefixed action and may change only its event type and payload; a key it returns is ignored. Letting it win would put the action vocabulary back beyond static reach, which is the whole point of the restriction.


11. Builtins

The vocabulary is fixed, not user-extensible.

builtinnotes
cardinality(x), count(x)array or object size
str(x)§6's rendering; what interpolation uses
not(b)
and(…), or(…)variadic, including zero arguments (and() is true, or() is false)
eq(a, b)deep equality; no cross-type coercion, so eq(1, "1") is false
lt, lte, gt, gtenumbers only
has(container, key)tolerant: a missing key, out-of-range index or wrong-shaped container answers false
lookup(container, key, fallback)dynamic access; the fallback is mandatory, so this never errors
map, filter, scan, foldalso core constructors — see below
concat(…)variadic array join; every argument must be an array
append(arr, item)adds one element; an array item is added whole, not spliced

scan is scanl, not scanl1: its output starts with the seed and is one longer than the input. fold takes the same (acc, item) step and returns only the final accumulator.

There is no arithmetic. No +, -, *, and no numeric builtins beyond the comparisons above — a template compares and selects, it does not compute. Numbers reach a program through $ctx, a literal, or a host-supplied library.

map/filter/scan/fold are core constructors rather than builtins because their function argument needs a fresh binding per element. branch is a core constructor because it must leave an arm unevaluated, which no builtin can do.


12. Errors

errorwhen
UnboundNamea name that is not bound and not a builtin
PathNotFounda field the value does not have; carries the path as written
TypeMismatchwrong type, wrong arity, a non-callable callee, a value that cannot cross a JSON boundary
ConcatMismatch<> over two different types
UnknownLibraryan import name the host table does not resolve
ImportCyclea library re-entered while already being evaluated
InLibrarywraps whatever an imported library failed with, naming that library; nests through a chain of imports

13. Implementation-defined

Programs must not depend on any of these.

  • Object key order, in values and in the serialized Node. str is the exception: it sorts, so it is deterministic.
  • Evaluation order, beyond binding order and Branch’s laziness.
  • Error message text. The error kinds above are stable; their prose is not.
  • Number precision. Numbers are doubles. Beyond 2^53 integers are not exact, and str renders whatever double survived.

14. Open

  • Array patterns, defaults and rest in binding patterns (§8).
  • Calling a call’s result directly (§7).
  • Types, domains and constraints. The AST is built to accept them: node annotations are where derived type/domain information goes, and the semantics avoid equating “unknown” with null, so a constraint-aware evaluator can reuse this AST rather than forking the language. The symbolic and constraint half of that is specified in v3-symbols.md and implemented in both hosts; nominal types are specified in v4-types.md — still a draft, not frozen, but implemented in both hosts through roadmap-to-v4 Phase 15 (tooling included: both the CLI and the browser playground surface the "types"/"type-constraints" envelope fields). v4 shipped as a minor bump — type X = ... declarations and @x : T = e annotations both fit inside the existing Let/Emit chain, so Program’s own shape (DocumentProgram | ExpressionProgram) never changed and no host that pattern-matches it is affected (roadmap-to-v4 Phase 15’s version decision).