Language Core Specification
The seven kinds of definition a Kumiki program is made of, plus the expressions, statements and patterns they share. §1.1 gives the shape of a whole program; after that, read the layer you are about to write.
1.1 Overall Program Structure
A Kumiki program is a set of 7 kinds of definitions. There are no physical file boundaries; each definition is stored in a content-addressable graph as the following 4-tuple:
(layer, name, body, content-hash)The textual representation is a projection from the graph and can be retrieved with kumiki view when needed (→ AI Editing).
program ::= definition*
definition ::= type-def | slot-def | effect-def | reducer-def | tile-def | fn-def | app-defDefinitions are unordered and may be forward-referenced. The compiler performs a topological sort.
1.1.1 List of Layers
| Layer | Role | Purity |
|---|---|---|
type | Types / schemas | Pure |
slot | Named global state | Pure initial value |
effect | Pure record value representing a side effect | Pure (execution is separate) |
reducer | message → slot change + effect emit | Pure (over the slot set) |
tile | Pure projection from slot → UI tree | Pure |
fn | Auxiliary pure functions | Pure |
app | Application entry | Declaration |
These seven are the logic/data/UI core — what an author must learn to express behavior. Kumiki also has auxiliary presentational/meta definitions that sit alongside the seven without expanding that core: theme (Design Tokens), motion (The motion definition), and test (Testing). They are real top-level definitions but are not counted among the seven layers; the production-grammar EBNF above lists only the core.
1.2 Lexical
identifier ::= [a-zA-Z][a-zA-Z0-9_-]* ; max 32 characters, longest munch
qname ::= identifier ('.' identifier)* ; dot-separated fully qualified name
cap-name ::= identifier ('.' cap-segment)* ; a capability, whose segments name nothing in this language
cap-segment ::= identifier | keyword ; so `telemetry.out` is a capability, not a syntax error
literal ::= number | string | bool | unit
number ::= int | float
int ::= '-'? [0-9]+
float ::= '-'? [0-9]+ '.' [0-9]+ ; the digits after the point are required
string ::= '"' (escape | non-quote-char)* '"'
escape ::= '\\' ('n' | 't' | 'r' | '"' | '\\' | 'u{' hex+ '}')
bool ::= 'true' | 'false'
unit ::= '()'
tuple ::= '(' expr ',' expr (',' expr)* ')' ; the value a Tuple types
comment ::= '#' until-eol ; single-line comment only- is both an identifier character and subtraction, and longest munch decides. page-size, base-url, on-401 and count-1 are one name each, which is why subtraction between a name and a literal is written count - 1.
Every spacing of count-1, and what an unresolved one reports
A - continues the identifier when an identifier character follows it, and ends it otherwise. count- 1 is also subtraction, because there the - has nothing to continue into; count -1 is subtraction because the space ended the name before the - was reached at all. A name written count-1 that resolves to nothing is E0103, and its message says so.
A # with whitespace on either side of it always starts a comment. It is the selector operator (§1.6.1) only in the tight form SaveBtn#new.
The exact condition, and where a # still comments
A # is the selector operator only when the character before it ends a value — an identifier character, or a closing ) / ] / } — and the character after it begins an identifier. Everywhere else, including #TODO at the start of a line and = 0# how many, it runs to the end of the line. A #id fragment therefore begins with a letter or _, which is what tile-ref already required.
Positions — line terminators, UTF-16 columns, and the BOM
A line is terminated by \n or \r\n; a lone \r is whitespace inside a line. A column counts UTF-16 code units, so an astral character advances it by two — the same convention the Language Server Protocol uses, and the one every consumer of a Kumiki position needs, since a patch splices a source line at column - 1. A leading byte-order mark is whitespace for the same reason: it is not part of the text, but it is part of the string being spliced, so it takes a column.
1.2.1 Operators
:= = == != < > <= >=
+ - * / % ->
&& || ! ; bool operators
& ; alias of `&&` (for ease of porting from other languages)
| ; type union / match arm separator (not bool OR — use `||`)
( ) { } [ ] , ; : . #Notes on bool operators:
- Short-circuit AND:
&&(recommended) or&(alias, internally identical) - Short-circuit OR :
||(recommended) or|(alias, but with a heuristic to avoid collision with match arms) - When writing
|as bool OR, if the following token is the combination "Variant/_+->" (i.e. the start of a match arm), the parser prefers to treat it as an arm separator. If any other expression follows, it is interpreted as bool OR. As a safe measure, use||when in doubt.
1.2.2 Reserved Words
type slot effect reducer tile fn app
nominal where when for in let if then else match with
on do emit cap out policy retry
true false
fresh self now nullnull is reserved but prohibited in programs (type error).
1.2.3 Design Decisions
- Indentation-independent: leading whitespace is ignored
- Newline is the statement separator: only inside
do=can;join multiple statements - Identifiers are at most 32 characters
- An expression, type, pattern or tile tree is at most 256 levels deep: past that the parser reports a positioned error. No program in the examples or benchmarks comes near it.
- Multi-line comments prohibited
- Macros prohibited
What the 256-level limit is measured against
A construct that contains itself — a parenthesised expression, a list, a record, an if, a statement body, a tile call, a tuple pattern, a type application, a theme record — may not nest further, and neither may a left-associative chain build more than that many nodes (1 + 1 + 1 + …, x.trim().trim()…, a run of not or -). The limit is on the resulting tree, not on how the parser reached it: every stage after the parse walks that tree by recursion, so a chain parsed by a loop still exhausts the stack downstream.
1.3 Type Layer (type)
1.3.1 Syntax
type-def ::= 'type' identifier ('(' type-param (',' type-param)* ')')? '=' type-expr
type-param ::= identifier
type-expr ::= primitive
| nominal-type
| record-type
| union-type
| generic-type
| refinement-type
| identifier
| type-app
primitive ::= 'Text' | 'Int' | 'Float' | 'Bool' | 'Unit' | 'Bytes' | 'Time'
nominal-type ::= 'nominal' type-expr
record-type ::= '{' field (',' field)* '}'
field ::= identifier ':' type-expr
union-type ::= variant ('|' variant)+
variant ::= identifier ( '(' type-expr (',' type-expr)* ')' )?
generic-type ::= identifier '(' type-expr (',' type-expr)* ')'
type-app ::= identifier '(' type-expr (',' type-expr)* ')'
refinement-type ::= type-expr 'where' pred-expr
pred-expr ::= identifier ('(' literal (',' literal)* ')')?refinement-type is recursive, so a type may carry more than one where, and the predicates conjoin: a value is accepted only when every one of them holds.
type Handle = nominal Text where len-gt(3) where len-lt(7)A Handle is longer than 3 characters and shorter than 7. The predicates are read through the chain a type denotes (§1.3.6, inv. 1), so they accumulate over a name as well as over one expression: on type Short = Text where len-lt(7), type Handle = nominal Short where len-gt(3) carries both. A generic that hands its parameter back is a link of the same chain (inv. 2): on type NonEmpty(T) = T where nonempty, NonEmpty(Short) carries len-lt(7) and then nonempty.
A value that fails is reported against the first predicate it fails, taking them in the order the chain is read, from the base outward. Inside one type expression that is the order they are written; across names it is the order the declarations lead through — Short's len-lt(7) comes before Handle's own len-gt(3) because Short is what Handle is declared over, wherever in the file either definition sits. That is the predicate the rejection names when a write discards a reducer's batch (batching) and the one whose message the error tile renders (Error Display).
1.3.2 Built-in Generic Types
Map(K, V)
Set(T)
List(T)
Option(T) ; None | Some(T)
Result(T, E) ; Ok(T) | Err(E)
Tuple(T1, ..., Tn)1.3.3 Registered Refinement Predicates
nonempty
len-eq(N) len-lt(N) len-gt(N)
between(A, B)
positive negative
email url uuid
regex("pattern")
one-of(v1, v2, ...)Every one of them is a runtime check: the value is tested on its way into the slot, and a write that fails it is refused (Forms §5.6, Runtime §10.3.3). What each one tests:
| Predicate | Holds when | Arguments |
|---|---|---|
nonempty | the value is text with at least one character | — |
len-eq(N) / len-lt(N) / len-gt(N) | the text's length is = N / < N / > N | one whole number, zero or more |
between(A, B) | the value is a number and A <= v <= B, both ends included | two numbers, A not above B |
positive / negative | the value is a number and v > 0 / v < 0 — zero is neither | — |
email | the text is local@host, the host carries a dot, and neither part holds whitespace or a second @ | — |
url | the text is absolute — a scheme, ://, then a host: https://kumiki.dev, not kumiki.dev | — |
uuid | the text is the 8-4-4-4-12 hexadecimal shape, any version, either case | — |
regex("p") | p matches the value whole: the pattern is anchored at both ends, so regex("[0-9]{4}") refuses "AB1234" | one text literal that compiles as a pattern |
one-of(v1, ...) | the value is one of the listed literals | at least one literal |
A predicate is checked wherever in the type it is written, not only on the type itself: a record field, a union variant's payload, and a container's element (List / Set members, Map keys and values, Option's Some, Result's Ok / Err, a Tuple's members) are positions of the value, and a value is accepted only when every predicate holds at every position it has. On
type Contact = {email: Text where email, age: Int where between(0, 120)}
slot form : Contact = {email: "[email protected]", age: 36}form.email := "nope" is refused exactly as form := {email: "nope", age: 36} is, and so is age := 999 by way of either. The positions nest and recurse — List(Contact), a recursive type Tree = {label: Text where nonempty, kids: List(Tree)} — and the check walks the value, which is finite. A refused value is reported against the first predicate it fails, fields in declaration order, with the path to where it failed: (email at .email), (nonempty at .kids[1].label), (nonempty at .Found) for a variant's payload, (len-lt(6) at [1]) for a list element. The error tile renders that predicate's message (Forms §5.7.1).
A path is the list of steps from the slot's value inward — RefinementFailure.path in the runtime — and the report writes each step this way:
| Step | Written | Position |
|---|---|---|
| a field | .email | a record's field |
| an index | [1] | a List element, a Tuple member |
| a variant | .Found, .Pair[1] | a union / Option / Result payload, by index when the variant carries several |
| a key | .keys["k"] | a Map key itself |
| an entry | ["k"] | the Map value stored under k, as Kumiki's own index reads it |
| a member | {"x"} | a Set member |
A key, an entry and a member hold the value as the key type reads it, so an Int key is [3] rather than ["3"]. The written form is for reading: the steps are distinct values, so a Map key is never mistaken for a field named keys, nor an entry under 1 for a list index.
Each position checks the value's shape before its predicates: a record and a Map are objects, a List and a Tuple arrays, a union / Option / Result value one of its variants. A value of the wrong shape there — a decoded {} where a list belongs, a payload missing a field — answers the position's predicates false, as the paragraph below says of any predicate, and is reported against the first of them.
Two positions are not walked. A Set member whose type does not come back out of the key the runtime stores it under — a type parameter, or Bytes — is not checked; a text, number or boolean member does, and so does a record, a variant or a tuple, which is stored as its JSON (Set({n: Text where nonempty}) is walked). And a generic that applies itself to a growing argument (type T(A) = {v: A, next: Option(T(List(A)))}) is a new type to lower at every level, so a slot whose refinement lies along one more than 32 levels deep is E0803 at build time rather than a check that stops partway. Distinct named types nest to any depth.
A predicate is a question about a value, so a value of the wrong shape answers it with false rather than raising: positive on text is false, and so is nonempty on a number. Written over a base of the wrong shape, then, a predicate refuses every value the slot can hold — Text where positive — and that is E0804. The len-* family, nonempty, email, url, uuid and regex need Text; between, positive and negative need Int, Float or Time; one-of compares strictly, so its literals need a Text base when they are text and an Int, Float or Time one when they are numbers. A generic's parameter is judged where the generic is applied: type NonEmpty(T) = T where nonempty is fine, and NonEmpty(Int) is E0804.
The set is closed, and a name outside it is a parse error. The arguments are checked too — a bound that is text, a fractional or negative length, len-lt(0) (shorter than every text), a pattern that does not compile, a range with nothing in it are all E0804, because a refinement no value can satisfy and one every value satisfies are the same defect. A registered predicate the toolchain does not lower is E0803 at build time rather than a check that silently passes.
Arbitrary Boolean predicates are prohibited. Reason: if the AI is forced to write proofs, the debugging loop breaks down.
1.3.4 Examples
type UserId = nominal Text where len-eq(36)
type Email = nominal Text where email
type Url = nominal Text where url
type Percent = nominal Float where between(0.0, 100.0)
type User = {id: UserId, name: Text where nonempty, email: Email}
type HttpError = {status: Int where between(400, 599), message: Text}
type LoadResult(T) = Idle | Loading | Loaded(T) | Failed(HttpError)1.3.5 Type Canonicalization
Structurally identical types have the same content-hash. Only nominal produces a new hash.
A nominal type is identified by the name it is declared under, so two declarations over one base are two types even when their bodies are identical:
type Cents = nominal Int where positive
type Yen = nominal Int where positiveCents and Yen do not accept each other — putting one where the other is required is E0201, and so is postId := userId on two nominal Text where uuid declarations. Comparing them is the same mistake and the same error: postId == userId and cents < yen are both E0201 (§1.9.4). An alias to a nominal names the same type (type Money = Cents), and a nominal written inline at a use site declares no name at all, so it is compared structurally like any other type expression.
The identity reaches the call that mints one too, which is where an id usually comes from: TypeName.fresh() is a TypeName and TypeName.parse(t) an Option(TypeName) (stdlib §2.4.1 / §2.4.3), so postId := UserId.fresh() is E0201 naming both types, and so is slot found : Option(PostId) = UserId.parse(t). fresh mints a uuid, so it is read for the nominal Text §2.4.1 scopes it to, and on a qualifier no uuid is a value of — a nominal Int, a record, a bare Int — the call is E0802 rather than a value of any type. A qualifier that names no type at all is E0117, and says nothing about the value either.
A type that carries no nominal name of its own meets any nominal declared over it, in both directions. That is what makes slot c : Cents = 1 legal without a construction form, and arithmetic yields the base (§1.9), so c := c + 1 stands.
A nominal declared over another nominal is a narrowing, and goes one way only:
type Deep = nominal CentsEvery Deep was declared a Cents, so a Deep is accepted where a Cents is required; a Cents where a Deep is required is E0201. Int still meets both.
Converting between two unrelated nominals goes through the base they share, and a fn is where that is written down — the return type documents the destination, and the body has to have reached the base:
fn toYen(c: Cents, rate: Int) -> Yen = c * rate
fn toUser(p: PostId) -> UserId = p + ""The arithmetic in the first lands on Int; the + "" in the second lands on Text. The identity body does not compile — fn toUser(p: PostId) -> UserId = p is E0201, because p is still a PostId. Nothing checks that such a fn converts anything; it records the intent, and the type of its body is all that is enforced.
A where refinement carries no identity of its own — type Positive = Int where positive is Int — and remains a runtime check (Forms §5.6) rather than a compile-time one: on type Volume = nominal Int where between(0, 11), volume := 50 is well typed, and the range is what validation decides.
1.3.6 Invariants
- A
typedenotes its body. An alias (type A = B), anominalwrapper and awhererefinement each stand for the type underneath, so following one has to arrive at a body. - An alias chain must not return to itself. Following a definition to the next name — through an alias, a
nominal, awhere, or a generic that hands one of its parameters straight back (type Alias(T) = T) — must not reach a name already on the chain.type A = A, and the pairtype A = B/type B = A, denote nothing: there is no body to reach. E0009. - The chain ends at a structural type. A record, a union, a container and a primitive are types in their own right, so nothing written inside one continues the chain.
- A recursive type is legal, and is the accepted form of a type written in terms of itself:
type Node = {value: Int, next: Node}
type Tree = {children: List(Tree)}
type Shape = Leaf | Branch(Shape, Shape)Each reaches a structural type before it reaches itself, by invariant 3. Comparing two of them terminates because the relation is read co-inductively over the types as written: re-entering a pair already being compared answers yes, which is sound because the finite part of the comparison has been checked on the way down. Termination does not depend on the values being finite — Node above has none, its next being neither optional nor a container — and it is still a legal type.
- A type parameter is scoped to its definition and shadows a top-level definition of the same name: in
type Alias(Cents) = Centsthe body is the parameter, whateverCentsis declared elsewhere.
1.4 Store Layer (slot)
1.4.1 Syntax
slot-def ::= 'slot' identifier ':' type-expr modifier? '=' init-expr
modifier ::= 'transient' | 'volatile'
init-expr ::= literal | record-literal | collection-literal | builtin-call| modifier | Meaning |
|---|---|
| (none) | Retained on hot reload; subject to persistence |
transient | Discarded on hot reload |
volatile | Not written to the episode log; discarded on hot reload |
At most one modifier: volatile already does everything transient does, so writing both says nothing volatile alone does not.
The initial value is required. A slot with no = would have to hold something before the program first writes to it, and the language has no value for that — no null, and no per-type zero.
1.4.2 Invariants
- All slots are global
- Mutation is only from a reducer's
do= - The initial value is a pure expression only (effects cannot be executed)
- Derived slots are prohibited (use the
fnlayer for derived computation). This includes the runtime'srouteslot, read directly or through afncall — it is installed by the mount, after every initial value has been evaluated (E0304)
1.4.3 Examples
slot todos : Map(TodoId, Todo) = {}
slot filter : Filter = All
slot draft : Text where len-lt(280) = ""
slot session : Option(SessionId) = None
slot password : Text volatile = ""
slot toast : Option(Toast) transient = None1.5 Side Effect Layer (effect)
1.5.1 Syntax
effect-def ::= 'effect' identifier
'cap' '=' capability-name
'in' '=' type-expr
'out' '=' type-expr
('policy' '=' policy-expr)?
('retry' '=' retry-expr)?
('map-request' '=' map-expr)?
capability-name ::= identifier ('.' identifier)+
policy-expr ::= 'latest' | 'latest-per-key' '(' expr ')' | 'queue'
| 'debounce' '(' duration ')' | 'throttle' '(' duration ')'
| 'once'
retry-expr ::= 'none' | 'linear' '(' int ',' duration ')'
| 'exponential' '(' int ',' duration ',' float ')'
duration ::= int 'ms' | int 's' | int 'm'
map-expr ::= record-literal ; conversion from high-level effect → low-level form1.5.2 Semantics
- An effect is a value (a pure record)
- A reducer emits it with
emit name(args) - Execution is performed by the runtime's effect dispatcher
- A capability check is performed before execution (if undeclared, compile-time error)
- The result is delivered to a reducer as
effect-name.ok($value, $key)oreffect-name.err($error, $key) policy=latest-per-key(<expr>)andmap-requestare the effect's own expressions: both are applied to the effect's input, so$1is the only bind, a slot and afnare readable, and$routeis not a name here- Both are checked like any other expression — an undefined name in the key is E0103, not a runtime failure at dispatch
- A
latest-per-keykey is evaluated where theemitruns, so a slot it reads sees the reducer body's writes up to that statement and none after it (http.md §6.4)
1.5.3 Examples
effect loadUser cap=http.get
in=UserId
out=Result(User, HttpError)
policy=latest-per-key($1)
retry=exponential(3, 200ms, 2.0)
effect persist cap=storage.write
in=Map(TodoId, Todo)
out=Result(Unit, Text)
policy=debounce(300ms)
map-request={key: "todos", value: $1}1.6 Reducer Layer (reducer)
1.6.1 Syntax
reducer-def ::= 'reducer' identifier
'on' '=' event-pattern
'do' '=' do-block
event-pattern ::= ui-event | effect-event | timer-event | lifecycle-event | route-event
ui-event ::= 'ui' '.' ui-kind '(' selector ')'
ui-kind ::= 'click' | 'submit' | 'change' | 'input' | 'focus' | 'blur' | 'key' | 'hover'
selector ::= tile-ref
tile-ref ::= identifier ('#' identifier)? ; TileName or TileName#id
effect-event ::= identifier '.' ('ok' | 'err') '(' bind (',' bind)* ')'
timer-event ::= 'timer' '(' duration ')' ; fires this reducer every intervalMs
lifecycle-event ::= 'app.start' | 'app.stop' | 'app.error'
| 'app.visible' | 'app.hidden' | 'app.online' | 'app.offline'
| 'app.http-401' | 'app.http-403' | 'app.http-5xx'
| 'tile.mount' '(' identifier ')'
| 'tile.unmount' '(' identifier ')'
route-event ::= 'route.enter' '(' string ')'
| 'route.leave' '(' string ')'
| 'route.error' '(' string ')'
bind ::= '$' identifier
do-block ::= statement-list
statement-list ::= statement ((';' | newline) statement)*
statement ::= assign | emit | let-stmt | if-stmt | match-stmt | for-stmt | block
assign ::= lvalue ':=' expr
emit ::= 'emit' identifier '(' (expr (',' expr)*)? ')'
let-stmt ::= 'let' identifier '=' expr
if-stmt ::= 'if' expr 'then' stmt-body ('else' stmt-body)?
match-stmt ::= 'match' expr 'with' ('|' pattern '->' stmt-body)+
for-stmt ::= 'for' identifier 'in' expr stmt-body
block ::= '{' statement-list '}'
stmt-body ::= block | statement-list ; newline-based. Stops at `else` / `|` / `}`
lvalue ::= path
path ::= identifier
| path '.' identifier ; field path (Option/Result auto-unwrapped)
| path '[' expr ']' ; index/key pathForms of stmt-body:
- Single statement:
if cond then x := 1 else x := 2 - Multiple statements (block):
if cond then { x := 1; y := 2 } else x := 3 - Multiple statements (newline): continues with newline/
;separators until reachingelse/|/}/ EOF
In other words, you can mix one-line layout and block layout. When writing in newline-based style, you only need to insert newlines so that the following statements stop at the right position before the next keyword (such as else).
Multiple subscriptions to the same event are allowed. When two or more reducers declare the same on= pattern (e.g. both subscribe to ui.click(SubmitBtn)), all of them fire in definition order — see §1.6.4 Invariant 3.
1.6.2 Selectors
A selector is TileName or TileName#id only (CSS attribute selectors have been removed).
reducer add on=ui.click(AddBtn) do= ...
reducer toggle on=ui.click(TodoRow) do= ...
reducer login on=ui.submit(form#login) do= ... # ❌ 'form' is a built-in element, not a tile nameTo bind events directly to built-in elements (button, input, form, etc.), create a wrapper tile:
tile LoginForm = form(...) {id: "main"}
reducer doLogin
on=ui.submit(LoginForm) # reference by tile name
do= emit login({...})TileName#id narrows a subscription to dispatched elements whose {id} prop equals id. A bare TileName reducer still fires for every instance; an #id-scoped reducer fires only when the runtime sees a matching id. Use it to make intent explicit when several tiles wrap the same built-in element, so the wrong one cannot quietly trigger the wrong reducer:
tile NewForm = form(submit-text="add", text=draft.new) {id: "new"}
tile EditForm = form(submit-text="save", text=draft.edit) {id: "edit"}
reducer add on=ui.submit(NewForm#new) do= ... # only the "new" form
reducer save on=ui.submit(EditForm#edit) do= ... # only the "edit" formThe {id} prop is also rendered as the element's native HTML id attribute. Multi-reducer rules from §1.6.4 Invariant 3 apply unchanged: a bare-TileName reducer and an #id-scoped reducer that both match the same event still run in definition order.
A selector on a container reaches the descendants that fire the event. Most container kinds fire nothing themselves — a box has no keydown, no focus and no submit of its own — so ui.<ev>(<Container>) is wired onto every descendant whose kind is in the allowed set for <ev> (§W0212 lists them). ui.hover is the unrestricted case: it is wired onto the container and onto each descendant, because mouseenter does not bubble.
How the descendant is written makes no difference. A body that names a child tile is walked exactly as an inline one, to any depth, so these two are one program:
tile Leaf = input(placeholder="type")
tile RefBox = box(Leaf) # a tile reference
tile Inline = box(input(placeholder="type")) # the same tree, spelled out
reducer typed on=ui.key(RefBox) do= ... # wired onto the input, both waysThe tiles a subscription reaches are decided by the path a descendant is rendered under, not by the descendant alone: a Leaf rendered beside RefBox is not inside it and receives no handler from it. When several enclosing tiles subscribe to one event on one descendant, §1.6.4 Invariant 3 applies as usual — every match fires, in definition order.
A handler written for that same event on a call site of the descendant tile (Btn {onClick: r}) joins the lifted subscriptions rather than replacing them: r and every enclosing tile's reducer all fire, in definition order (§1.7.3). So a subscription on a container reaches every descendant that fires the event, a button with its own handler included: ui.click(TodoRow) on a row holding a checkbox and a delete button runs on both. Subscribe to the narrowest tile the event means — ui.click(TodoCheck) with tile TodoCheck = check(...) — rather than to the row around it.
1.6.3 lvalue Semantics
An lvalue is a path, and you can directly mutate nested fields or the contents of an Option. The compiler expands this into an immutable update.
The steps a path may take are a closed set: a field (.name), an index ([key]), and .get on an Option / Result. Nothing else is an lvalue — in particular a stdlib member is not one, and name.length := 9 is E0602 rather than a write. There is nothing for such a write to mean: a member derives a value from the receiver, so there is no place in the receiver for the assignment to land.
# These reducer statements:
todos[id].done := true
editor.title := "New"
editor.get.body := "Body" # via Option (compiler expands to Option.map)
# are internally expanded as:
todos := todos.update(id, $1.copy(done=true))
editor := editor.copy(title="New")
editor := editor.map($1.copy(body="Body"))Going via .get is safe: assigning when the Option is None is a no-op (does not panic). If you want to explicitly panic, write editor := Some(editor.get.copy(body="Body")). .get is the same polymorphic unwrap it is when read (Standard Library §2.2.4), so a Result behaves alike: the write edits an Ok payload and skips an Err. Note that only the assignment is safe — a right-hand side that reads editor.get while the Option is None still panics.
An index step names a place in its receiver, and what it names depends on the receiver:
Map(K, V)— the entry at the key.m[k] := vinserts or replaces it;m[k].f := vwrites a field of the entry, and on an absentkwrites nothing, as theupdateit expands to does. A readm[k]at an absentkis a panic (Lifecycle §7.2.2), as a List index past the end is: there is noVfor it to answer.m.get(k)is the read that answersNonethere instead. In a tile the read runs during render, so an absent key panics that render, the first one included: the nearesterror-boundaryshows its fallback, and with none the built-in panic display replaces the page (Lifecycle §7.3). A tile reads a key that may be absent throughm.get(k)orm.get-or(k, d).List(T)— the element at the position, and the index is anInt(anything else is E0201).xs[i] := vreplaces the element atiin a newListof the same length, andxs[i].f := vwrites through it; every level keeps its shape. An index that names no element —ipast the end, or negative — is a panic (Lifecycle §7.2.2): the reducer's writes roll back andapp.errorruns. A readxs[i]panics at the same indices, so the two sides of:=agree;xs.get(i)is the read that answersNonethere instead. To grow a list, writexs := xs.push(v).Set(T)— nothing. A Set has membership and no places, sos[x] := vis E0602; membership changes through.add/.remove/.toggle(Standard Library §2.2.2).
The name is dispatched, not reserved: on a record that declares a field named get, rec.get.title := v writes that field. Both sides resolve .get by the same rule — a record's own field wins, otherwise it is the unwrap.
A bind= target reaching through .get is a read as well as a write, so it panics while the value is empty: input(bind=draft.get.title) with draft = None fails during the first render and the app does not mount. Reach such a control through a match on the Option, the way any other .get read is reached.
.copy(field=value, ...): a shortcut for an immutable update of a record. It looks like a method call, but internally the named args are collected and expanded into recordCopy(rec, {field: value, ...}). You can update multiple fields at once:
editor := editor.copy(title="New", body="Body", updatedAt=now)
issue.copy(status=Done, priority=High)1.6.4 Invariants
- Pure function: input = (slot set, event payload), output = (new slot values, emit set)
- Direct execution of effects is not allowed. Only emission via
emit - Multiple reducers matching the same event run in definition order
- Writing to the same lvalue path is allowed at most once within a single reducer (path-shape granularity, E0601)
- Duplicate detection is done by the shape of the path.
issues[k].statusandissues[k].updatedAtare different paths → can coexist - Writing the same shape twice is a violation:
x := 1; x := 2✗ - In the mutually exclusive branches of
if/match, each branch is counted independently. Writing the same shape in both the then and else is OK (at runtime only one of them runs) - Examples:
issues[iid].status := s; issues[iid].updatedAt := now✓ (different field paths)if cond then x := 1 else x := 2✓ (mutually exclusive branches)x := 1; x := 2✗ (same path, sequential)if cond then x := 1 else x := 2; x := 3✗ (same path again after combining mutually exclusive branches)
- Even with the same shape, different index values (
m[k1]andm[k2]) cannot be statically decided, so they are treated as 1 write (the stricter side). If you want to update multiple keys, use aforloop
- Duplicate detection is done by the shape of the path.
- Calling
fnis allowed (safe because it is pure) - The batch commits all-or-nothing: if any slot's new value violates its type's refinement, the entire reducer application is discarded — no slot write, no
emit, nostop-timer— and the rejection is reported (see batching). A reachable bound is the program's business: write the guard.volume := volume + 1onVolume = nominal Int where between(0, 11)✗ at 11 (rejected and reported)if volume < 11 then volume := volume + 1✓
- A slot read reads the value the body most recently wrote to that slot, wherever the read sits: in a
matcharm, in a statement-levelif/match, in the body of alet … in, in a method's predicate or element lambda (names.filter($1 == noteKey)), or directly in the body. AfternoteKey := "b", every read ofnoteKeythat runs later in that application is"b". A read that runs before any write to that slot in this application reads the value the slot held when the reducer started.
1.6.5 Positional Binding
| Syntax | Meaning |
|---|---|
$1, $2, ... | the bind order of an effect-event; within a fn, the argument order; within a tile, the tile's in= argument ($1 only — a tile takes a single positional argument) |
$el | the {...} props of the tile that fired the event |
$event | the event payload |
$route | the Route at route.enter / route.leave / route.error, and in a link's prefetch target — nowhere else (Routing §3.4). Any other reducer reads the route slot |
$now | the current time |
An
effect-eventtrigger cannot bind these names. The compiler declares$el,$eventand$routein every reducer body, seeded from whatever the trigger's payload carries — so a bind that takes one of them is a second declaration of the same name, andon=load.ok($el, _)is E0121. The numbered binds stay bindable; nothing else declares one.
A bind list's names must be distinct. A bind list names the payload's positionals in order, so two binds naming one thing leaves the other positional with no name to read it by —
on=load.ok(dup, dup)is E0123._is exempt however often it is written, and occupies a position rather than skipping one: it is the spelling for a positional the reducer does not read.
The first bind has the type the effect's
out=declares. Onout=Result(T, E),load.ok($v, _)binds$v : T; any otherout=is the whole value on.ok. Sosession := $vinto a slot of another type is E0201, and a member call on$vanswers fromTas it would on a slot of that type. On.err, an effect on a storage / session / indexed capability binds$e : Text: those effects deliver the failure's message, also when themap-requestor a host provider throws (Storage Effects), soproblem := $estores it and$e.messageis E0108. Theirout=must say so —Result(T, Text); any otherEis E0306..erron any other capability is not typed fromout=: an HTTP handler delivers its{status, message, body}record and a custom capability's value is its provider's (Standard Capabilities), so reads of$ethere are not checked. The second bind (the request key) and a built-in effect's result have no declared type either.
In a
fn, a positional is a parameter.$1is the first parameter and$2the second — the same value, with the type the parameter declares — sofn plus(a: Int, b: Int) -> Int = $1 + $2isa + b. There is one positional per parameter:$1in afnwith none, or$2in a one-parameterfn, is an undefined reference (E0103). A fragment inside the body binds its own$1/$2, which shadow thefn's: infn dbl(xs: List(Int)) -> List(Int) = $1.map($1 * 2)the receiver isxsand the fragment's$1is each element.
$1in a tile requiresin=. A tile may reference$1(e.g.todos[$1]) only if it declares anin=argument type —tile TodoRow in=TodoId = … todos[$1] …. Using$1in a tile with noin=is an undefined reference (E0103): there is no positional argument to bind. See Examples.
1.6.6 Examples
reducer addTodo
on=ui.submit(NewTodoForm)
do= let id = TodoId.fresh()
todos[id] := {id, text=draft, done=false, createdAt=now}
draft := ""
emit persist(todos)
reducer toggle
on=ui.click(TodoCheck)
do= todos[$el.todoId].done := not todos[$el.todoId].done
emit persist(todos)
reducer loaded
on=loadUser.ok($user, $id)
do= users[$id] := Loaded($user)
reducer editTitle
on=ui.input(TitleInput)
do= editor.get.title := $event.value1.6.7 Scoping and Shadowing
A name declared over one already in scope shadows it: every read below the declaration is the inner binding, and the outer one is back once the scope that declared the inner one ends. Every declaring form binds this way — a let statement, a for bind, a match-arm pattern, and the let … in expression (§1.9).
The scopes are the reducer body and every nested statement body inside it: a for body, each branch of an if, and each match arm including a catch-all _. A binding declared in one of those ends with it.
A declaration's own right-hand side is evaluated before the name it declares is in scope, so let n = n + 1 reads the binding it shadows rather than the one being declared.
Binds written side by side are peers rather than a shadowing pair, because nothing nests them: two binds of one pattern (Both(a, a)) are E0122, and an effect-event bind list is the same shape (E0123). A name repeated there is not a shadow — it is a value the pattern or the trigger has left with no name to read it by.
A let may take one of the positional bindings. Those are declarations like any other, so the let wins for every read below it — including a $route in a reducer whose trigger binds none, which is that let's value and not a payload read (E0119). An effect-event bind is the one position that cannot take one of the names (E0121): a bind list names the payload's positionals, so a bind that took $el would have nowhere left to put the positional it stands for.
reducer shadowing
on=ui.click(Btn)
do= let $el = "chosen" # wins over the positional binding
let label = "first"
let label = label + "+second" # the right-hand side reads the `let` above
for label in tags { hits := hits + 1 } # binds inside the loop only
note := label + "/" + $el # "first+second/chosen"1.7 View Layer (tile)
1.7.1 Syntax
tile-def ::= 'tile' identifier
('in' '=' type-expr)?
('sub-routes' '=' route-map)?
('error-boundary' '=' identifier)?
('scroll-restoration' '=' bool)?
'=' tile-expr
tile-expr ::= tile-call
| match-expr
| control-flow
tile-call ::= identifier '(' (tile-arg (',' tile-arg)*)? ')' ('{' prop (',' prop)* '}')?
tile-arg ::= (identifier '=')? expr
prop ::= identifier ':' expr
control-flow ::= when-expr | for-expr | if-expr
when-expr ::= 'when' '(' expr ',' tile-expr ')'
for-expr ::= 'for' identifier 'in' expr tile-expr
if-expr ::= 'if' expr 'then' tile-expr 'else' tile-expr
match-expr ::= 'match' expr 'with' match-arm+
match-arm ::= '|' pattern '->' tile-expr
pattern ::= identifier
| identifier '(' bind (',' bind)* ')'
| '_'( … ) vs { … } — arguments/children vs props:
( … )is the argument & children list: positional child tiles (column(A, B)), value arguments (heading("Hi")), and named arguments (button(text="Save", onClick=r),input(bind=draft)). A child tile or another tile call goes here.{ … }is the props block:key: valuepairs only — style/layout/ARIA props and event-handler bindings ({pad: "lg", gap: "md"},{todoId: $1},{onClick: r}). It contains no tile calls and no children. Writing a tile call inside{ … }(e.g.link(to="/x") {text("Home")}) is a parse error.- A tile's label/content is passed in
( … ): it is the first positional value-arg for the text builtins (text("Home"),heading("Hi"),code("…")) — a named argument is a prop wherever it is written, soheading(level=2, title)saystitleandlevelstays a prop — and a named arg forbutton(button(text="Save")).linkandlabeltake either: their first positional argument (link("Home", to="/x"),label("Name")), ortext=when none is written (link(to="/x", text="Home")) — so, as foreditable,text=beside a positional argument is never read. (linkadditionally accepts the older{text: "…"}prop form, which most existing examples use; all compile to the same node.) An argument written as content that the builtin never reads — a second positional argument,text=on a text builtin with no positional one, ortext=beside a positional one onlink/label/editable— is E0129:text=is the label argument ofbutton/link/label/editable, and a prop on the text builtins. - A positional argument of a builtin other than the value builtins (
text,heading,markdown,code,editable,label,link,image,icon) renders only when it is a tile — atile-expror the name of a defined tile. A value there —column(text("a"), 42), a slot,column(let x = 42 in Card(x))— renders nothing and is E0128: show it with a tile (text(n.show)), write it where it is used, or compute it in afn. Where a value belongs (a value builtin's content, a user tile's input, a named argument) aletis a value, and is checked like one. A tile body and awhen/if/for/matcharm are atile-exprthemselves, so aletthere is a parse error.
Semantics of when(cond, tile):
condis true → rendertilecondis false → omit that child from the tree (no effect on siblings)- If the parent tile is
column(A, when(c, B), C), then withc=false,[A, C]is rendered - Because the runtime skips null/undefined children,
whenis a safe way to produce a "blank"
Value context vs tile context for match:
- A
matchwithin the positional arguments of thetext/heading/markdown/label/link/image/iconbuiltins is treated as a value expression (MatchExpr). Each arm returns a value (Text, Int, etc.) - A
matchwithin any other tile argument (column,row,card, etc.) is treated as a tile expression (TileMatch). Each arm returns a tile - Example:
text(match m with | A -> "a" | B -> "b")← value match - Example:
column(match xs with | Loaded(ys) -> ... | None -> spinner())← tile match
1.7.2 Invariants
- Pure function: input = (slot set, in argument), output = UI tree
- Slot writes are not allowed
- Effect emit is not allowed
- Direct recursion is prohibited. Mutual recursion only when depth can be proven at the type level
- The iteration target of
foris onlyMap.keys,Set.to-list, orList - Within the value expressions of tile properties
{...}, reading slots is allowed (for the purpose of fixed capture of event-handler arguments) - Calling
fnis allowed
1.7.3 Event Handler props
An event handler takes a reducer name, and may be written on a user tile as well as on a builtin. On a user tile it is a prop like any other: it is merged onto the node that tile renders — onto each of them, when the body is a for that renders a list — so Btn(onClick=tap) and Btn() {onClick: tap} are the same wiring, and whether it fires is a question about what Btn renders. Merged means joined: the handler adds to whatever that node already dispatches for the event — a handler written on the builtin itself, one written on another call site the node is the root of, and every ui.<ev>(<Tile>) subscription lifted onto it (§1.6.2). Each matching reducer runs once, and all of them run in definition order, as §1.6.4 Invariant 3 says of any reducers matching one event; where a handler happens to be written does not decide what runs first. W0213 asks that question at both kinds of call site, by walking the tile's render tree: a handler on a user tile that renders no kind able to fire it is reported the same way one written on an inert builtin is. A named argument is never the tile's input — that is the positional one — so a tile declaring in= still takes it: Row(onClick=tap, label) passes label as $1.
button(text="Save", onClick=saveTodo) {todoId: $1}With onClick=saveTodo, the reducer saveTodo is called on click. {todoId: $1} is delivered to the reducer as $el.todoId.
The name is resolved in the reducer namespace and only there, whatever its capitalisation: onClick=Bump binds the reducer Bump, and a name that names no reducer is E0102 — a tile written there included. What capitalisation decides is the shape the parser gives the name, and nothing else; it is not a way to say which layer is meant. A capitalised name is a tile call when it is a named argument of a builtin that takes tiles (box, button, input, modal, form), and a variant tag everywhere else: in a props block, as a named argument of a value-arg builtin such as link, and as a named argument of a user tile.
Because those shapes carry no arguments, the bare name and the argument-less call are one and the same after parsing — onClick=Bump, onClick=Bump() and onClick=Bump {} are the same node, and all three bind the reducer. A value that carries arguments is not a name and is E0201.
1.7.4 Examples
A tile that takes a positional argument declares its type with in= and reads the argument as $1. $1 is available only when in= is declared — using $1 in a tile with no in= is an undefined reference (E0103). Callers pass the argument positionally: TodoRow(id).
tile TodoCheck in=TodoId = check(value=todos[$1].done) {todoId: $1}
tile TodoRow in=TodoId
= row(
TodoCheck($1),
text(todos[$1].text) {strike: todos[$1].done},
button(text="x", onClick=remove) {todoId: $1})
tile TodoList = column(
for id in todos.keys
when(matchFilter(todos[id], filter),
TodoRow(id) {key: id.show}))
tile App = page(
heading("Todos"),
NewTodoForm,
TodoList,
text(itemsLeft.show + " items left"))1.8 Function Layer (fn)
1.8.1 Purpose
To reuse pure auxiliary computations under a name. Callable from tile / reducer / other fn.
1.8.2 Syntax
fn-def ::= 'fn' identifier
'(' (fn-param (',' fn-param)*)? ')'
('->' type-expr)? ; return type (inferred if omitted)
'=' expr
fn-param ::= identifier ':' type-expr1.8.3 Invariants
- Pure function: input = arguments only, output = value only
- Reading/writing slots is prohibited (receive them via
fnarguments) - Effect emit is prohibited
- lvalue not allowed (no assignment)
- Calling other fn is allowed, direct recursion is prohibited, mutual recursion only when depth can be proven at the type level
1.8.4 Examples
fn matchFilter(t: Todo, f: Filter) -> Bool
= match f with
| All -> true
| Active -> not t.done
| Done -> t.done
fn itemsLeft(ts: Map(TodoId, Todo)) -> Int
= ts.filter(not $2.done).size
fn visiblePosts(posts: Map(PostId, LoadResult(Post)), tag: Option(Text)) -> List(PostId)
= posts.entries
.filter(matchPostTag($2, tag))
.sort-by(loadedAt($2))
.map($1)
fn matchPostTag(lr: LoadResult(Post), tag: Option(Text)) -> Bool
= match (lr, tag) with
| (Loaded(p), Some(t)) -> p.tags.find($1 == t).is-some
| (Loaded(_), None) -> true
| _ -> false1.8.5 Calling from tile / reducer
tile TodoList = column(
for id in todos.keys
when(matchFilter(todos[id], filter), TodoRow(id)))
tile Counter = text("Left: " + itemsLeft(todos).show)
reducer normalize
on=ui.click(NormalizeBtn)
do= todos := normalizeAll(todos)
fn normalizeAll(ts: Map(TodoId, Todo)) -> Map(TodoId, Todo)
= ts.map($2.copy(text=$2.text.trim))1.8.6 Partial Application and Higher-Order Functions
Since there are no lambdas, passing higher-order functions uses either a "fn name" or an "expression fragment":
items.map(double) # registered fn name
items.map($1 * 2) # expression fragment ($1 is the element)
items.filter(matchFilter($1, filter)) # embed a fn call in an expression fragmentPartial application is written explicitly (no currying):
fn isActiveOnly(t: Todo) -> Bool = matchFilter(t, Active)
items.filter(isActiveOnly)A fn name in a fragment position is the call the method makes with the fragment's positionals: items.map(double) is items.map(double($1)), and xs.fold(0, add) is xs.fold(0, add($1, $2)). It is the only position a bare fn name is right — a fn is not a value, so label written where a value goes is E0127, and the call is label(). The positions, and how many positionals each binds, are listed under E0127.
1.9 Expression Language
The common expressions used in the right-hand side of a reducer's do=, inside a tile, and in the body of a fn.
expr ::= literal
| qname ; slot, let-binding, fn-arg, builtin reference
| expr '.' identifier ; field access
| expr '[' expr ']' ; index
| expr binop expr
| unop expr
| 'if' expr 'then' expr 'else' expr
| 'match' expr 'with' match-arm+
| 'let' identifier '=' expr 'in' expr
| call
| record-lit
| collection-lit
| '(' expr ')'
call ::= qname '(' (expr (',' expr)*)? ')'
record-lit ::= '{' (field-init (',' field-init)*)? '}'
field-init ::= identifier '=' expr | identifier
collection-lit ::= '[' (expr (',' expr)*)? ']'
| '{' (entry (',' entry)*)? '}'
entry ::= expr ':' expr
match-arm ::= '|' pattern '->' expr
pattern ::= identifier ; a union variant, or a binding name
| identifier '(' bind (',' bind)* ')' ; variant with payload binds
| '(' pattern (',' pattern)* ')' ; tuple
| '_' ; wildcard
binop ::= '+' | '-' | '*' | '/' | '%'
| '==' | '!=' | '<' | '>' | '<=' | '>='
| '&' | '|'
unop ::= '-' | '!'An if and a match evaluate to one of their branches, so every branch has to fit where the expression lands. p := match ou with | Some(id) -> id | None -> p is E0201 at the Some arm when ou is an Option(UserId) and p a PostId — each arm is read with the types its pattern binds, exactly as p := ou.get-or(p) is. Where nothing declares a type (a let, an operand), the expression has its branches' common type. When the branches disagree, that is the base they share, with the nominal dropped: a UserId branch beside a PostId branch gives Text. The expression has no type, and nothing is reported against it, only when the branches share no base or one branch's type cannot be decided.
1.9.1 Prohibitions
- Lambda expressions prohibited
try/catchprohibitednull/undefinedprohibitedwhileloops prohibited- Assignment expressions prohibited (
:=is a statement and cannot be used within an expression) - Literal patterns prohibited. A
matchpattern is a union variant,Variant(binds), a tuple, or_— only. Patterns matching against a literal value (match s with | "Overdue" -> … | "Today" -> …, or numeric/bool literals) are not supported and fail to parse.matchis for destructuring a union/variant, not for branching on aText/Int/Boolvalue. To branch on a value, useif/else(or chainedif), or model the cases as a union type and match on that:
# ❌ literal patterns — not supported
match label with | "Overdue" -> red | "Today" -> amber | _ -> gray
# ✅ branch on a value with if/else
if label == "Overdue" then red else if label == "Today" then amber else gray
# ✅ or lift the cases into a union and match the variant
type Urgency = Overdue | Today | Later
match urgency with | Overdue -> red | Today -> amber | Later -> gray1.9.2 Alternatives to Higher-Order Functions
items.map($1 * 2) # expression fragment
items.map(formatPrice) # fn name
items.filter(matchFilter($1, filter)) # fn call
items.fold(0, $1 + $2.price) # ($1: acc, $2: elem)1.9.3 Short-Circuit Evaluation
& and | use short-circuit evaluation.
1.9.4 Operator Types
Every operator's operand and result types, which the compiler checks (E0201):
| Operator | Operands | Result |
|---|---|---|
+ | Text on either side | Text — concatenation; the other side is stringified |
+ - * % | both numeric | Float if either operand is Float, else Int |
/ | both numeric | Float, always |
< > <= >= | both numeric, both Text, or both Time | Bool |
& | | both Bool | Bool |
== != | any two values, unless they carry two different nominal identities | Bool |
unary - | numeric | the operand's type |
unary ! | Bool | Bool |
/ is Float even between two Ints. It is JavaScript's / at runtime — 5 / 2 is 2.5, not 2 — so an Int result type would be a promise the runtime does not keep, and fn half(x: Int) -> Int = x / 2 is rejected. Take .to-int (truncating, stdlib §2.2.7) where a whole number is wanted, or declare the Float.
== compares by value. Kumiki values are immutable (§1.6.3), so a program has no reference identity it could mean to compare: two Lists, tuples, records, Maps or variants are equal when what they hold is equal, all the way down — xs == [] on an empty list, (0, 0) == (0, 0) and Some({x: 1}) == Some({x: 1}) are true, [1, 2] == [2, 1] is false. A record's fields and a Map's entries compare regardless of the order they were written in. != is the negation. List.contains and List.unique ask the same question (stdlib §2.2.3), and so do the test layer's comparisons. A Set compares as the keys it is stored under (stdlib §2.2.2), which depends on how the Set was built, so equality over Sets is not a rule a program may rely on.
== is total over every shape — an Int and a Text, an Option and its None — and nominal is the one exception. Two declarations over one base are two types (§1.3.5), so comparing them is the same mistake as assigning one to the other and is the same error: postId == userId is E0201, exactly as postId := userId is. Ordering refuses the pair for that reason as well as for the family one — cents < yen shares the number family and is still E0201.
The comparison is refused only when both sides carry a nominal name and neither was declared as the other, which is the assignment rule read symmetrically: a value with no identity of its own compares with any nominal over it, so cents == 0 and postId == "" stand as the assignments do, and a Deep declared nominal Cents compares with a Cents in either order. Converting is the same fn through the shared base that an assignment needs.
The identity is read at the top level of each operand only. A nominal inside a type argument, a container or a record field is invisible here, which is where the operators and assignment part company: List(Cents) := List(Yen) is E0201, and lc == ly on the same pair is not reported. That silence is a missing diagnostic rather than a wrong one, which is the reading every rule in the check keeps.
EffectId is outside this table: only == and != apply to it (E0204).
1.10 Namespaces and Reference Resolution
- A flat global namespace
- A separate namespace per layer
- References are written by name and resolved to a content-hash when stored in the CRDT graph
- Rename = a CRDT op that creates a different hash under the new name and updates references
1.11 content-hash Computation
hash(def) = blake3(
canonical(def.body)
⊕ hash(direct-dependency-1)
⊕ hash(direct-dependency-2)
⊕ ...
)1.12 Application Entry (app)
app-def ::= 'app' identifier
'caps' '=' '[' (capability-name (',' capability-name)*)? ']'
'routes' '=' route-map
('init' '=' '[' emit-list ']')?
('theme' '=' identifier)?
('http' '=' http-config)?
('meta' '=' meta-config)?
('indexed-db' '=' idb-config)?
('analytics' '=' analytics-config)?
route-map ::= '{' route-entry (',' route-entry)* '}'
route-entry ::= string '->' identifier ; to a tile name
| string '->>' string ; static redirect
emit-list ::= effect-call (',' effect-call)*app TodoApp
caps = [storage.read, storage.write, http.get]
routes = {"/" -> TodoList, "/todo/:id" -> TodoDetail, "/404" -> NotFound}
init = [loadTodos()]
theme = DefaultTheme1.12.1 When init arguments are evaluated
An init entry is an effect call, and its arguments are ordinary expressions — a slot reference included. They are evaluated once, when the app object is constructed, and the resulting values are never re-read: a later change to a slot is not reflected in an argument already captured.
What a slot reference sees at that moment is its declared default. route is the exception — it is maintained by the runtime and does not exist yet, so init = [load(route.path)] is a compile error (E0120), as is $route, which the runtime does not bind here either. Take the route from a route.enter reducer instead.
The restriction is on what an argument reaches, not on how it is written: init = [load(here())] where fn here() -> Text = route.path is the same error, reported at the call with the chain that gets to the route. A fn that reads the route stays legal everywhere else — a tile, a reducer and an effect's map-request all run after the mount that installs it.
now is available and is captured the same way: it evaluates to the moment the app object was built, not to the moment the effect runs. An entry that needs the time of its own dispatch should take it in the reducer that handles the result.
There is no reducer around these arguments either, so an emit expression is not available in one (E0305): the entry itself is the dispatch.
1.13 Counterexamples
# ❌ local state
tile Foo = let x = 0 in button(text=x.show) # a tile body has no `let`: a parse error here, E0128 as a child
# ❌ direct effect call
reducer r on=ui.click(B) do= http.get("/") # emit required
# ❌ lambda
button(onClick=(() -> count + 1)) # not allowed, only a reducer name
# ❌ null
type User = {name: Text | null} # use Option(Text)
# ❌ arbitrary predicate
type Even = Int where ($1 % 2 == 0) # registered predicates only
# ❌ reading a slot inside a fn
fn current() = todos # receive it via a fn argument
# ❌ CSS attribute selector
reducer r on=ui.change(input[type=file]) do= ... # write it by tile name
# ❌ literal match pattern
match status with | "open" -> ... | "closed" -> ... # patterns are variant/_ only; use if/else or a union (Prohibitions)
# ❌ $1 in a tile with no in=
tile Row = card(text(issues[$1].title)) # E0103: declare `tile Row in=IssueId = …` (Positional Binding / Examples)
# ❌ a tile call inside the props block
link(to="/x") {text("Home")} # `{...}` is key:value props only; pass the label as text="Home" (Syntax)1.14 Complete Example: Counter
type N = nominal Int where between(0, 999)
slot count : N = 0
reducer inc on=ui.click(IncBtn) do= if count < 999 then count := count + 1
reducer dec on=ui.click(DecBtn) do= if count > 0 then count := count - 1
reducer reset on=ui.click(ResetBtn) do= count := 0
tile IncBtn = button(text="+")
tile DecBtn = button(text="-")
tile ResetBtn = button(text="reset")
tile App = column(
heading("Count: " + count.show),
row(DecBtn, ResetBtn, IncBtn) {gap: "sm"})
app Counter
caps = []
routes = {"/" -> App, "/404" -> App}
init = []