Status: normative. Applies to version: 0.90.0 (the
VERSIONinselfhost/src/version.dawn). When the implementation conflicts with this document, this document wins and the implementation is a bug — unless some clause here is explicitly marked "superseded by X".The title read "v0.1 draft" for a long time; by the day that was changed the toolchain had already reached 0.11. A document that calls itself a draft cannot act as a judge, and this is the only document in the repository qualified to judge disputes about semantics. The version number follows
VERSION; it is no longer numbered separately.
This document is the authoritative definition of the syntax and semantics. For design motivation
see design.en.md. The repository keeps no separate formal grammar file: when the
grammar is in dispute, this document and selfhost/src/front/parser.dawn win; the executable
expectations live in scripts/grammar-corpus/.
Wording of this specification: must (violating it is a compile error), guaranteed (behaviour the implementation promises), undefined (not promised by this specification; do not rely on it).
1.Source files and lexical structure#
1.1Source files#
- UTF-8 encoded, extension
.dawn. - One file is one module (see §10).
1.2Comments#
# Line comment, to end of line
## Doc comment, attached to the declaration that immediately follows (extracted by the toolchain)
There are no block comments. The only comment is the one that starts with # and runs to the end
of the line; to comment out a stretch of code, put # on each line.
/* is a lexical error (Dawn has no block comments; use `#` on each line), reported at the /*;
the text after it up to */ (or to the end of the file if there is none) is skipped as a whole, so it
raises nothing further.
Rationale: Dawn's line structure carries meaning (§1.7: a newline separates statements, and the
line's last token decides whether it continues), and interpolation is single-line (§1.6). Without
block comments every line can be tokenized on its own, "what is the last token of this line" never
depends on earlier text, and dawn fmt needs no indentation rule for comments that span lines.
Gleam and Zig likewise have only line comments.
Doc comments. A whole-line comment that starts with ## is a doc comment (so is ###; the third
# belongs to the text); a comment that starts with a single # means nothing to the toolchain.
- Whole line: apart from indentation (spaces, tabs), the line holding a doc comment holds only that
comment. A
##written after code (a trailing comment) is not a doc comment, and for the attachment rule its line is a code line. - Text: drop the
##and at most one space after it; the rest is the line's text. Further leading spaces are kept (for indented code and lists). A line of just##is an empty text line. The texts of consecutive lines are joined with newlines. The toolchain does not parse the text and hands it to consumers as written; the site and editors render it as Markdown. - Attaching to a declaration: the contiguous doc-comment lines right above a declaration's first line
form that declaration's doc. A blank line, a plain
#line or a code line (including a code line with a trailing comment) breaks the run; a##above the break does not belong to the declaration. - Declarations that take a doc: top-level
fn,type,const,trait,effect; a trait's associated types, associated effects and methods; an effect's operations; a type's constructors and fields (record fields and a constructor's named fields); methods inside an impl. A member follows the same line rule as a top-level declaration and is read above its own line, except that a constructor or field written on the same line as its owner (thetypehead, or its constructor's head) has no doc of its own: the block above that line documents the owner, not the member, anddawn docgivesnullfor it. When a member has no doc of its own, editor hover may show its owner's doc instead; that is a presentation choice, not the member's doc. Impl headers, local declarations (let, local fn, lambda) andtestblocks take no doc; a##written above them is only a comment, and is not an error. - Module doc: the contiguous doc comments from line 1 of the file are the module's doc if the line
after them is not the first line of a top-level declaration (
fn,type,const,trait,effectorimpl); otherwise they are that declaration's doc (animplheader publishes no doc, but a block right above animplis still not the module doc). A blank line, a plain#line, auseline or atestblock after them leaves them the module doc. To document both the module and the first declaration, separate the two blocks with a blank line. A file whose line 1 is not a doc comment (for example a maintainer's#header) has no module doc. - What is published:
dawn docpublishes the module doc and the docs ofpubdeclarations and their members:fn,type(with constructors and fields),const,trait(with associated types, associated effects and methods),effect(with operations). Impls publish no doc:dawn doclists an impl only asTrait[Type], and docs on impl headers and impl methods are shown only by editor hover; docs onpub(pkg)and private declarations are for the editor only (§10.4). Links:
[`name`]in the text is a reference to a declaration. This is the only spelling: a plain code span is not a link; followed by(or[it is an ordinary Markdown link; and nothing inside a fenced code block counts.nameis resolved in the scope of the module the doc is written in, that is, the way that module's code could spell the name:- this module's top-level declarations (private ones included), bare, or qualified by the last segment
of the module's path (
io.exitwritten inside std/io); - a module alias bound by
use, then a dot:list.map, which sees only the names that module exports to this one; the alias on its own (list) names the module; - a selectively imported name, by its local name when renamed with
as; - the prelude: builtin functions, the prelude traits and their methods,
Option/Result/ForeignErrorand their constructors, the builtin types; - members:
Type.Ctor,Type.field,Trait.method,Trait.Item,Effect.op, withOwnerresolved by the rules above, also writtenmodule.Owner.member; constructors, effect operations and trait methods may also be written bare, as in code.
The case of the first letter decides whether a type or a value is looked for first (a record type and its constructor of the same name: the type); there are no disambiguation prefixes. A module that is not imported cannot be linked to, and a
usewritten only for a doc is an unused import (a compile error), so what a link can see is what the code can see.dawn docresolves the links in every doc it publishes: when one resolves to nothing it reports each one asmodule:linewith the reason, prints no JSON and exits non-zero; the resolved ones go in alinksarray beside that doc (the target's module path, name and kind). Compiling (check,run,build) does not read links.- this module's top-level declarations (private ones included), bare, or qualified by the last segment
of the module's path (
- Examples: a declaration's runnable examples are not written in its doc comment but as tests named
by the declaration (
test f { ... }, §3.4);dawn docpublishes apubdeclaration's examples beside its doc (examples). A```dawnfence in the text is only an illustration: it is neither compiled nor run.
Rationale: a doc comment is still a comment. Whether a comment is a doc depends only on its own line
(does it start with ##, is there code before it), so, as above, every line can be tokenized on its own
and dawn fmt needs no rule of its own for it; attachment is decided by the single line rule "contiguous,
right above", with no extra node in the syntax tree. If a trailing ## counted too, the next line's
declaration would claim it, though it speaks about the code on its own line. Rust's, Zig's and Gleam's
/// all have this same shape. The link notation is Rust's and KDoc's [`name`]: in CommonMark it is an
undefined reference and renders literally, so a renderer that does not know it still reads it. Names in
backticks are not all taken as links: most of what this repository writes in backticks is code, and which
of it counted as a link would be impossible to predict. Resolution lives in the tooling, not the compiler:
comments carry no meaning, and dawn run should neither refuse to compile over a typo in a comment nor
pay for reading one.
1.3Identifiers and naming conventions#
The enforced part — the first character decides the syntactic category, and the parser uses it
to disambiguate (in pattern matching x is a binding and X is a constructor; TYPEIDENT is a
separate token):
- Values, functions, module names: the first character is a lowercase letter or
_ - Types and constructors: the first character is an uppercase letter
- Effect variables:
!followed by a value identifier (iois a reserved effect name)
Style conventions — not enforced by the toolchain, but followed throughout the repository:
values use lower_snake_case ([a-z][a-z0-9_]*), types use UpperCamelCase
([A-Z][A-Za-z0-9]*).
On non-ASCII: "uppercase / lowercase" is decided by Java's
Character.isUpperCase, and subsequent characters byCharacter.isLetterOrDigitor_. Sofn 中文()andfn _hidden()both compile — a Han character is neither uppercase nor lowercase, so it takes the "not uppercase" branch, i.e. it is a value identifier.This is implementation-defined, not designed: Unicode's XID_Start/XID_Continue, normalization (whether the two spellings of
écount as the same name), and homoglyphs are all undefined by this specification, and code that depends on them is not portable. Scripts without case (Han, Arabic, kana) can therefore only be value names; there is no natural way to write them as type names. Converging on a well-defined Unicode identifier syntax is open work (SYN-01 in docs/codebase-audit.md).
1.4Keywords#
fn let var type alias const use pub
match if else for while
return break continue
comptime
trait impl effect
true false not
Keywords cannot be used as identifiers. panic and todo are built-in functions, not keywords.
There are twelve further contextual keywords. Each has a special meaning in exactly one
syntactic position and remains an ordinary identifier everywhere else (let in = 1 and a field
named test are both legal):
| Word | Its one syntactic position |
|---|---|
java | use java "a.B": after use, followed by a class-name string (§9.1) |
test | at the head of a top-level declaration: test "name" { … } (§3.4) |
assert | at the start of a statement, followed by an expression (§3.4); in assert = 1 and assert.x it is a name |
with | at the start of a statement, followed by a name or <- (§4.10) |
in | in a for header, after the pattern (§4.7) |
derive | at the tail of a type declaration (§3.5) |
as | in the renaming position of use (§10.2) |
handle | after with, when the next token is not <- (§6.5) |
opaque | directly before type, introducing an opaque type (§2.7) |
ctl | directly before effect, declaring a suspendable effect (§6.5) |
resume | after a handler arm's parameter list: op(a) resume k => … (§6.5) |
pkg | only after pub(: pub(pkg) (§10.4) |
The statement-start tests are "what a plain name could never legally be followed by": a name
directly followed by an expression or by another name is never a legal program, so giving those
sequences to the keyword takes no legal spelling away. The one cost is that a function named
assert cannot be called as assert(x) at the start of a statement (that is an assertion).
Both lists are reconciled by machine: the hard keywords are the keyword table in
selfhost/src/front/token.dawn, and the contextual keywords are the is_word(…, "<word>") sites
in selfhost/src/front/parser.dawn. scripts/doc-check.py and the editor grammar's contract test
(editors/vscode/test/scope-contract.js) both read those two places, so a word missing from this
section or from the grammar file is red.
Symbol tokens take the longest match (as with >>>/>=/->/|>). The binding arrow <- of
the with statement (§4.10) follows the same rule: a<-b reads as a <- b, not a < (-b). The
spaced a < -b is unaffected, and dawn fmt puts a space on both sides of every binary operator
(§1.8), so formatted source never reaches the second reading.
1.5Literals#
| Form | Type | Notes |
|---|---|---|
42, 1_000_000, 0xFF, 0b1010 | Int when nothing is expected | 64-bit signed; underscores may be used as separators; typed by its expectation (below) |
3.14, 1.0e-9 | Float when nothing is expected | IEEE 754 double; typed by its expectation (below) |
true / false | Bool | |
"hello" | String | see §1.6 |
() | Unit | the only value |
[1, 2, 3] | List[Int] | trailing comma allowed; separators and element forms in §4.11 |
(1, "a") | (Int, String) | tuple, 2 to 8 elements |
'a', '\n', '世', '\u{1F600}' | Char | character literal (see below) |
The non-negative magnitude of an integer token must normally be in
0..9223372036854775807. The sole exception is exactly 2^63, and only when it is the direct
operand of unary -: decimal -9223372036854775808, hexadecimal -0x8000000000000000, and the
corresponding 64-bit binary spelling all denote the minimum Int. Expressions and literal
patterns use the same rule. Parentheses around the whole negative value remain valid
((-9223372036854775808)), but parentheses separating the magnitude from the minus do not
(-(9223372036854775808)), nor does bare 2^63. A magnitude greater than 2^63 is a range error;
a digit forbidden by its radix (such as 0b2) is an invalid literal rather than a range error.
A numeric literal has no type of its own; its expected type types it (since 2026-10-06,
literal-system-design.md). A literal atom is an integer literal, a
float literal, or either one directly after a unary -. When it is checked with an expected type
T:
TisFloat: an integer literal is theFloatof the same value, and one that has no exactFloat(|n| > 2^53with no double exactly equal to it) is a compile error.-0is negated as an integer first and is+0.0; a negative zero is written-0.0.Tis a type this module cannot see through and there is an impl ofFromInt[T](for an integer literal) orFromFloat[T](for a float literal): the literal is that impl'sfrom_int/from_floatapplied to the literal's value (§3.5). A float literal is rounded toFloatfirst and then handed tofrom_float, so it is bit for bit what an explicit conversion such asbf16(x)gives; a literal of more than seventeen significant digits that lands exactly on a midpoint of the narrow format can therefore differ by one ulp from rounding the decimal directly. An integer literal never goes throughFromFloat, and a float literal never becomes an integer type.- An opaque type the declaring module sees through is typed at its target (§2.7); a literal there is exactly what it always was.
- Otherwise, including when nothing is expected, an integer literal is an
Intand a float literal aFloat, and a disagreement with the expectation is reported as before.
When the impl is pure, the compiler computes the literal's value at compile time (the same
interpreter as comptime, §7), and a panic in the impl is a compile error pointing at the literal:
the range of U8 is written in its FromInt impl, which is why let b: U8 = 300 does not compile
although the compiler does not know 255. When the impl has an effect, the literal is a call at run
time and the effect is charged to the enclosing signature (§6); such a literal cannot appear in a
const or a comptime block. Typing a literal never feeds back into the type of anything else:
there are no inference variables and no defaulting rules. How the expectation reaches a literal: for
a binary operator see §4.3 (the literal yields to the typed side); among the arguments of a generic
call, a literal whose parameter still names an unsettled type parameter is checked after the other
arguments have typed themselves and before those that need an expectation, a float literal before
an integer literal on the same type parameter (g(1, 2.5) has T = Float, and in g(s, 1) the 1
takes the type of s). Literals in patterns are not part of this and follow §5.1 as before.
A character is its own type, Char: one Unicode scalar value (0..0x10FFFF, excluding the
surrogate range D800..DFFF). It is an opaque type (§2.7) over Int whose owner is
std/char — the representation is the code point, so it is zero-cost, and ==, <, hashing and
literal patterns in match all reuse the Int ones. The rendering comes in two layers (§4.3), and std/char
writes one for each: impl Display[Char] is the top level, so to_string(c) and "${c}" give the
character, while impl Show[Char] is the nested one, so a Char inside a structure ([c], a
record field, a tuple) renders as the source literal 'a', escaped with the set above, the way
a nested String renders as "a". str.from_char(c) asks for that top-level
one-character string by name, without depending on an impl. But it is not an Int: 'a' + 1 does not
hold, and converting between the two goes through std/char — char.code(c) -> Int takes the code
point, char.of(n) -> Option[Char] builds a character from a code point (None if it is not a
scalar value). Inside single quotes is a single code point: the escapes are the same as in strings
(\n \t \r \\ \u{...}) plus \'; an empty literal, more than one code point, or a \u{...} that
is not a scalar value → lexical error. For the functions that handle strings by code point see §11
(code_points -> List[Char]/from_code_points/len/str.slice/str.at).
Note: the runtime storage is java.lang.String (indexed by UTF-16 code units), so "random
access by code point index" needs an O(n) conversion — for the measurements and the design
trade-off see the appendix of `seq6-research.md`. For character-by-character
traversal use the cursor of §11 (std/cursor), whose cost per step is constant; the indexed
version is for one-off calls.
cursor.charis the exception: it returnsInt, notChar. At the end it answers-1(a named exception in §4.8), and a sentinel that is not a code point cannot live in a type where every value is a code point. TheCharsurface isstr.at,str.charsandcode_points;cursor.charis the primitive underneath them.History: up to and including v0.56.0,
'a'was anInt(Go's rune route). The type nameCharlanded in v0.54.0 and thestd/charmodule in v0.56.0, each one release early and as a transparent spelling ofInt, purely to satisfy the seed discipline (docs/bootstrap.md): the compiler that compiles the release doing the flip must already know that name and that module. The flip was done in one go in v0.57.0. For the phasing, the measurements and the several routes that were rejected see `audit/nominal-types-design.md` §7.
1.6Strings and interpolation#
Double-quoted strings, with the escapes \n \t \r \\ \" \$ and Unicode \u{1F600}.
Braces { } are ordinary characters and need no escaping — convenient for writing JSON, CSS
and code generation.
Interpolation has one spelling: ${expr} inserts an arbitrary expression (a name is written
${name} too). The interpolated type must have a Show witness — the
built-in scalars, user types that write impl Show or derive Show, and containers and tuples
whose elements are renderable (see §4.3):
let n = 3
println("got ${n} items, first = ${list.get(0)}")
A $ directly followed by a character that can start a name (a letter or _) is a lexical
error; the diagnostic names both repairs, ${name} to interpolate or \$name for the literal. A
$ followed by anything else (a digit, a space, punctuation, the end of the string) is still a
literal dollar sign ("$5" needs no escaping); to write a literal $ before { use \$.
(Before v0.78.0 there was a short form $name that took the longest identifier, so "$obj_x" and
"$obj.name" silently read a value the author did not write. It was removed rather than re-read as
a literal because re-reading it would make every program written the old way silently print other
text; an error makes every occurrence visible. dawn fmt from v0.78.0 through v0.79.0 rewrote
$name to ${name}; from v0.80.0 it only reports the error, see
`syntax-window-design.md` §2.) The effects of the expressions inside an interpolation
are unioned into the effects of the whole string expression.
A ${...} must fit on one line (spanning lines reports interpolation cannot span lines), and
that holds inside a triple-quoted string too: what may span lines is the string, not one ${...}
within it. Otherwise an interpolation takes anything: the } that ends it is the one reached
after skipping whole literals, so a } inside a string, a triple-quoted string, a raw string or
a character literal ("${'}'}", "${`}`}") does not count.
Multi-line strings use triple quotes """; the leading and trailing newline and the common
indentation are stripped. The interpolation rules are the same.
Raw strings use backticks: everything between `...` is literal — no escapes, no
interpolation, may span lines, indentation not stripped, what you see is the value. The one
restriction is that the content cannot contain a backtick itself (and there is no escape hatch; to
write a backtick, use an ordinary string). The blind spots of the three forms complement each
other: contains a backtick → "..."; a template that needs interpolation → """; verbatim text
containing quotes and $ (regexes, code samples, HTML) → backticks.
1.7Newlines and semicolons#
Statements are separated by newlines; there are no semicolons — writing a ; gets a dedicated
diagnostic (telling you to delete it) and is then recovered as a newline, so it does not take
everything after it down with it. A line that ends in a binary operator, |>, a comma or an
opening bracket continues automatically; in addition, |>, . and binary operators are all
allowed at the start of the next line (vertical pipelines / vertical method chains / vertical
boolean or arithmetic, the idiomatic spelling — a Java builder chain
x\n .uri(u)!\n .build()! can be broken across lines, and so can a long condition
a\n && b\n && c). The sole exception is + and -: a leading - is ambiguous with unary
negation (is x\n - y the expression x - y, or a new statement -y? there is no way to tell),
so that pair of arithmetic operators does not continue a line from the start of one. By convention,
one statement per line; dawn fmt makes it uniform.
A line ending in =, ->/<-/=>, ., not or : continues as well (let a: with the type
on the next line is one statement); the set is written once, in the lexer (continues_line), and
dawn fmt derives its continuation indent from it, so the two cannot disagree.
A newline doubles as a separator in two places: between match arms (§5) and between the elements of a list literal (§4.11). Both parse the expression by the rules above first, and the newline left standing afterwards is the separator, so continuation wins and separation follows.
1.8dawn fmt#
dawn fmt <file>... formats in place; dawn fmt --check <file>... only reports files that are not
formatted (exit code 1 if there are any, for CI). The implementation is a reprinter over the
token stream: it reprints token by token as-is (strings and interpolations are kept exactly as
their source ranges, not a character changed) and adjusts the whitespace between tokens —
intra-line spacing, 2-space indentation, collapsing consecutive blank lines (the author's physical
line breaks are kept). The sole token normalization omits the optional first | of a single-line
sum type declaration (§2.3); all other tokens and comments are preserved. Formatting is
idempotent, and it only needs the lexer to succeed (not the parser), so a file with syntax errors
can still be formatted.
A successful lex is a precondition, and failing it is a refusal: a character the lexer rejects
produces no token, and tokens are all that get reprinted, so formatting such a file deletes it.
So dawn fmt does not write back a file that does not lex — it renders the diagnostics, exits 1,
and leaves the file untouched (--check likewise); the files in the same batch that do lex are
formatted as usual. A directly named non-.dawn file is refused the same way (exit 2):
directory mode already filters by extension and a named path did not, so one path typo was
enough to feed Markdown to the Dawn formatter and overwrite it.
The rules: indent 2 spaces; 1 space on each side of a binary operator /
-> / => / = / |>; 1 space after ,/: and none before; nothing inside (/[; ./?
tight; .. tight on both sides (a..b; where it is a prefix instead — the record spread
{ ..base }, the list/constructor/record rest [x, ..rest] — the space to its left belongs to
the opening bracket's or the comma's rule); over-long lines are not wrapped (lines the author
broke are kept).
2.Types#
2.1Basic types#
The non-generic, compiler-owned basic types users may name directly are Int (64-bit), Char
(a Unicode scalar value), Loc (a call site, §8.4), Float (double), Bool, String, Bytes (an immutable byte sequence),
and Unit. These eight names, together with the three public generic names in the next section,
are the complete public builtin type surface.
Unit is a first-class value (its only value is ()) and may appear anywhere a value can
appear: parameters, local variables, closure captures, tuple elements, return values, and as an
instantiated type parameter (Result[Unit, E], List[Unit], catch_fault(() => <void call>)).
At runtime it has a real representation — a singleton object (dawn/rt/Unit, the same
representation as None and as constructors without fields), occupying one reference slot; the C
backend gives it one byte.
There is only one exception, and it is not a restriction of the representation:
- A constructor field cannot be
Unit— a branch with no payload is simply written as a bare constructor; this is a statement about modelling.
Like the other scalars, Unit has the four impls Eq/Hash/Ord/Show (§3.5), and every answer
is a constant: () == (), cmp((), ()) == 0, hash(()) == 1 (the seed of the synthesized hash:
there are no parts), and it renders as (). The constant itself is useless; the use is in
composition: Result[Unit, E] can therefore be compared, printed and used as a key. (Before
2026-09-24 these impls were withheld on purpose, for exactly the reason "the answer would be a
constant"; builtin-privileges-design.md §2 records why that
accounting was wrong.)
There is no null. A value of any type is necessarily valid; possible absence is expressed with
Option[T].
There are no implicit conversions. Int → Float must be written explicitly as to_float(n).
A numeric literal is not a value of Int or Float; it is typed by §1.5, so let f: Float = 1 is
legal and let f: Float = n is not.
2.2Composite types and naming layers#
The generic, compiler-owned types users may name directly are exactly List[T], Map[K, V], and
Set[T]:
List[T]— an immutable persistent list. The implementation is a persistent vector: a 32-way trie plus a tail block (std/pvec, pure Dawn source, built on theArrayprimitive): indexing is O(log32 n) — at length ≤32 everything is in the tail block, i.e. one array read;++appends element by element, copying only the tail block while it is not full (≤32 slots), and every 32 appends it pushes the tail block into the trie and copies one root-to-leaf path, so the accumulating loopacc = acc ++ [x]is linear, O(1) amortised. Structure is shared and a published list never changes; appending to an old version does not copy the whole table, only its own small piece.Map[K, V]— an immutable map (see below)Set[T]— an immutable set (see below)
The remaining composite types do not belong to that builtin-name inventory:
- Tuples
(A, B, ...)and function typesfn(A, B) -> C !eare structural types, with no nominal type name;!emay be omitted, meaning pure. Option[T](Some(T) | None),Result[T, E](Ok(T) | Err(E)), andForeignErrorare ordinary nominal ADTs injected by the prelude; they are not compiler-owned builtin types.Array[T]is a compiler-owned representation primitive nameable by the bundled std. A user module cannot reach that primitive through this name; anArraydeclared outside std is simply the user's own nominal type.Neveris the compiler-owned bottom type and a hard-reserved name. It has no values or constructors. It may be written directly only as the return type of a top-level function, local function, trait or impl method, effect operation, or any function type. Thereforefn() -> Neverandalias F = fn() -> Neverare valid. Parameters, fields, const or let annotations, generic, collection, tuple, and other storage positions, associated bindings, and the direct aliasalias N = Neverare invalid.type,alias,trait,effect, constructor names, and type parameters cannot shadowNever. A function declared to returnNevermust itself diverge. Inference keeps the same rule: a tuple or list literal component of typeNeveris only the bottom type, as a constructor argument is. It takes the component type the context supplies, and without one it is reported as not inferable, so(Never, Int)andList[Never]are never inferred. ANeverpart of a string interpolation is evaluated as usual, what follows it is unreachable, and it needs noShow. LSP completion offers the name only in function-return contexts.dawn doc --builtinsmarks it with"use": "return"rather than presenting it as a globally usable public builtin.io.exitstill returnsUnit; this rule does not change its existing API.
In type position, (T) is grouping only: after parsing it is still T, with no extra type
node. A tuple still has at least two elements: (A, B) is a tuple, (T,) is not a one-element
tuple, and empty () is not a type either (the unit type is written Unit).
Function-type arrows remain right-associative, and a suffix effect belongs to the function layer it immediately follows. When the return type is itself a function, parentheses separate an outer effect from that returned function:
fn() -> fn() -> Int !io # pure outer function, !io returned function
fn() -> (fn() -> Int) !io # !io outer function, pure returned function
fn() -> (fn() -> Int !io) !io # both layers are !io
Option and Result are ordinary prelude ADTs, with no special status
(the ? operator has syntactic support for them, see §8.1).
Map[K, V] / Set[T] are built-in persistent containers on a par with List. They have no
literal syntax and are operated on entirely through built-in functions (the list is in §11). The
semantics that matter:
- Persistent (immutable) interface:
map.insert/map.removereturn a new map; the original value is unchanged. The key type = a type that has
Hash, and there is no second rule. Every operation that touches a key (map.insert/map.get/map.has/map.remove/m[k]and the same-named ones onset) carries a[K: Eq + Hash]bound, and legality of a key is exactly that bound being solvable —Int/String/Bool/Bytes, tuples, ADTs and records all work (one is synthesised from the type's own structure when no impl is written, §4.3),Floatdoes not (NaN and-0.0give it two different equalities, which is why §4.3 does not give itHash),Arraydoes not (it is held by identity, not by content). The error lands on the operation that uses the key, not on the type annotation: the typeMap[Float, Int]can itself be spelled out, there is simply no operation that can put a key into it.Before 2026-07-27 there was a separate structural walk (
invalid_key_part) running over type annotations and generic instantiation points. It was parallel to the bound and fought with it: it bannedBytes(on the grounds that "hashing is reference identity", whileByteshad long since been changed to content hashing) where the bound let it through; and it only stoppedFloatin key position, sohash(1.5)still passed anywhere else. What was deleted is that walk, not the rule.- Iteration order = insertion order, deterministic and identical on JVM and native
(
map.keys/map.entries/set.to_listfollow insertion order). On a key that already exists,map.insertreplaces the value and keeps the original insertion position. - Equality does not depend on order: two
Maps with the same key-value pairs are equal, whatever the insertion order. - The implementation is a persistent HAMT (
std/hamt, pure Dawn source, built on theArrayprimitive):map.insert/map.removeare O(log32 n), copying only the root-to-leaf path and sharing the rest of the structure with the original map (insertion order is maintained by a per-key sequence number). Equality and hashing of keys come from the key type'sEq/Hash(passed in as dictionaries), not from the host's object identity.
2.3Sum types (ADT)#
The first | is optional; the formatting convention omits it on a single-line declaration
and puts a bar before each constructor line in a multi-line declaration.
type Shape =
| Circle(r: Float)
| Rect(w: Float, h: Float)
| Point # constructor with no payload
- Constructor fields must be named; a construction call may pass by position or by name:
Rect(2.0, h: 3.0). - A sum-type constructor uses parentheses only, in construction and in patterns alike:
Rect { w: 2.0, h: 3.0 }is an error ("constructorRectis built with parentheses"), and so isRect { w, .. }in a pattern ("constructorRectis matched with parentheses"). Braces belong to records (§2.4): whatever brackets the declaration uses, construction and matching use too, one spelling for one thing (since v0.78.0, which closed the second spelling; `syntax-window-design.md` §3). - The bare name of a constructor (one with fields) in function position is an ordinary function
value:
map(xs, Some)is equivalent tomap(xs, x => Some(x)). Type parameters are inferred from the expected function type (the element type ofSomecomes from the context); a type parameter that the fields alone cannot determine (such as the error typeEofOk) must be supplied by the context, otherwise the compiler reports "cannot infer type parameter". A constructor with no payload (Point) is itself a value, not a function; record construction uses the brace syntax and does not take part in this rule. - Generics:
type Tree[T] = Leaf | Node(left: Tree[T], value: T, right: Tree[T])
2.4Records#
type Point = { x: Float, y: Float }
let p = Point { x: 1.0, y: 2.0 }
let q = Point { ..p, x: 3.0 } # functional update
let d = p.x # field access
A record is sugar for a single-constructor product type, and supports pattern matching just the same. Fields are immutable — "modification" is functional update.
A record can only be built with braces, and only matched with them. Point(1.0, 2.0),
Point(x: 1.0, y: 2.0) and Point() are all errors ("record Point must be built with braces"),
whether the parentheses were written directly or produced by a pipeline (§4.4); Point(x: a) in a
pattern is an error too ("record Point is matched with braces", since v0.78.0). The reason is that spelling and meaning
correspond one to one: fields in the brace form must be named, and filling a record positionally
would turn field order into an ABI; a record's bare name is not a constructor function either
(§2.3, last bullet). x |> Point { ... } is not "filling in fields" — it calls a record value,
and is reported as not callable.
A field of fn type can be called directly: r.f(x) calls the function value stored in the
field, equivalent to let g = r.f followed by g(x); the field's effects are unioned into the
caller as usual. When a function named f also exists in scope, the method spelling r.f(x)
is a compile error (ambiguity) — a silent precedence would let a new function of the same name,
added somewhere far away, quietly change the meaning of an existing call (§10.3 already rejected the
same kind of ambiguity for module aliases of the same name; the rule here is the same).
Disambiguation: to take the field, write (r.f)(x) (or bind it first with let g = r.f); to call
the function, name it directly as f(r, x).
The ambiguity belongs to the one spelling r.f(x): it is the syntactic form of a method call
(§4.3). The other spellings that apply r.f as a value — parentheses (r.f)(x), the pipe
x |> r.f (§4.4: when the right side is not a call, it is applied to the left value as a whole),
and with v <- r.f — have a field read as their callee, so when the record has a fn-typed field
f they always take the field, whether or not a function of that name is in scope; a new function
of the same name cannot change their meaning, so the reason above does not apply. (When the record
has no field f, these spellings still call the function f by UFCS today; whether to tighten
that is a separate question.)
2.5Generics#
- Type parameters are declared after the name with
[T, U]:fn map[T, U](xs: List[T], f: fn(T) -> U !e) -> List[U] !e - Monomorphism: type parameters must be fully inferable at every call site; higher-kinded types (HKT) are not supported.
- There are no call-site type arguments: a type parameter is fixed only by the arguments and the
expected type.
f[Int](x)(Go's spelling of explicit instantiation) means "index the functionfwith the expressionInt" in Dawn; the compiler recognises that shape and reports a single "Dawn has no call-site type arguments" (when the subject is a generic function or builtin and the index spells a type). To fix the type, annotate the binding that takes the result or the place an argument comes from. - Implemented as erasure + boxing; monomorphisation is an optional later optimisation and does not affect semantics.
- No subtyping, no inheritance, no variance. Types are either equal or different.
- A local annotation inside a function body may refer to the signature's type parameters
(
let acc: List[T] = []) — a rigid type parameter is treated in inference as a known concrete type.
2.6Type aliases#
alias Meters = Float # built-in scalar
alias Pair = (Int, String) # tuple
alias Names = List[String] # generic application
alias Handler = fn(Request) -> Result[Response, HttpError] !io # function type
alias Lookup[T] = fn(String) -> Option[T] # may carry type parameters
alias Paint = Color # user types (ADT/record) can be aliased too
Aliases have their own keyword, alias; type declares only nominal types (an ADT or a
record). An alias is transparent (not a newtype): it is expanded during resolution and is fully
interchangeable with the type it names.
alias Meters = Floatprovides no unit safety whatsoever. It is fully interchangeable withFloat:MetersandSecondscan be added together, and passing a bareFloatwhere a function takesMetersgoes through fine. The first line above is only a syntax example of "an alias can name a built-in scalar" — do not read it as a recommended practice for domain modelling; unit types are the most misleading use of alias. The real value of aliases is in the lines below it: giving a long type a short name (Handler,Lookup[T]).For unit safety, what you want is the
opaque typeof the next section:pub opaque type Meters = Floatis interchangeable outside its module with neitherFloatnorSeconds, and getting in and out is two one-line functions (§2.7). This sentence used to read "Dawn does not have this yet" — that was written before §2.7 landed and nobody came back to change it (the root of LANG-05 is exactly examples and signposts drifting out of sync, and this sentence committed the same sin itself).
Historical note: the two once shared
type, told apart by a "shape of the right-hand side" heuristic — same form, different meaning, and user types could not be aliased (a bare capitalised name was always read as a constructor). Nowtype X = <fn type/tuple/Name[...]/built-in scalar>is a compile error whose hint points atalias;type Color = Redkeeps its ADT meaning unchanged.
Restrictions: an alias cannot be recursive (a cycle is an error); an effect variable must be bound
by the parameter list (alias Mapper[T, U, !e] = fn(T) -> U !e), and writing an unbound one is an
error, while a named label and !io are simply written (§6.3); with pub it can be imported across
modules (use m.{Handler}).
2.7Opaque types#
pub opaque type UserId = Int # only this module knows it is an Int
pub opaque type Env = Map[String, Int]
pub opaque type Pair[T] = (T, T) # may carry type parameters
opaque type N[..] = T has the same syntax as alias, with exactly one difference: who is
allowed to see through it. Inside the module that declares it, N and T convert to each other;
outside that module they are different types.
# inside module ids
pub fn wrap(n: Int) -> UserId = n # ✅ convertible inside this module
pub fn unwrap(u: UserId) -> Int = u # ✅
# in another module
let bad: Int = wrap(7) # ❌ annotated type is Int but the
# initializer is UserId
Conversion happens at assignment, argument and return positions (that is, wherever type
assignability is decided), not inside an expression: u + 1 is still wrong inside the module;
write let n: Int = u and then compute, and the result converts back automatically when it reaches
a UserId position. This is the discipline of a newtype, and it also keeps "opaque" to a single
decision in the implementation rather than special cases scattered everywhere.
Opacity blocks the view, it does not change the semantics: at runtime an opaque type is its
target type — the same representation, the same equality, hashing and ordering (Eq/Hash/Ord,
the target's unless the type writes its own), on both backends, at zero cost.
opaque is a soft keyword; only opaque type means anything.
What it inherits is the relations, a closed allowlist. An opaque type with no impl of its own falls
back to its target for Eq, Hash and Ord only. Every other trait (Show/Display, arithmetic,
FromInt/FromFloat, format-identity traits such as HasDtype, and any trait a user or library
declares) asks only the type's own impls, and a missing one is a compile error: no impl of … /
… has no such impl. There is one criterion: a relation only answers true/false or a sign, exposes
no representation and mints no value; every other trait describes the type's identity, format or
behaviour, and borrowing the target's answer would hand the target's identity to a type that
declared "I am not it". (Since 2026-10-07; before that the rule was "fall back by default, with a
blacklist of Show, arithmetic, FromInt/FromFloat", every new trait meant one more special case, and
HasDtype[U16] silently resolved to Int's "i64", the opposite of the "should be a type error"
std/dtype declares. Haskell newtypes, Rust newtypes, Scala 3 opaque types and Kotlin value classes
all inherit nothing by default; GHC's GeneralizedNewtypeDeriving reuses the representation's
dictionary, again except for Show/Read. To inherit, write a two-line forwarding impl in the
declaring module.)
The next two paragraphs are what this rule means for rendering and arithmetic.
Rendering. An opaque type does not inherit its target's Show (and so
not its Display either). A relation only answers true/false or a sign and exposes nothing; a
rendering prints the representation as it is, which is exactly what the type hides. To print one,
write impl Show[N] in the declaring module (and impl Display[N] when needed); without it,
to_string, ${…}, a field under derive Show and a [T: Show] bound are all compile errors for
it. (Since 2026-09-24; before that it was inherited, and fourteen handle types in std and
packages would print a handle number or internal state. GHC's GeneralizedNewtypeDeriving draws
the same line: it reuses the representation's dictionary, except for Show/Read, which do not
look through. The audit and the verdict per type are in
builtin-privileges-design.md §4.)
Nor does an opaque type inherit its target's arithmetic (Add/Sub/Mul/Div/Rem/Neg,
§3.5, §4.3): arithmetic makes a new value of the type, and only the declaring module knows how
that value keeps the type's invariant (summing handle numbers, or skipping the re-rounding of a
narrow float, would be silently wrong). To have +, write impl Add[N] in the declaring module;
without it, u + u and a [T: Add] bound are compile errors for it, Char included (convert with
char.code first). (Since 2026-10-06; before that, arithmetic on an opaque type was an error too,
only because arithmetic accepted nothing but Int/Float. GHC draws the same line: Num does not
come with a newtype for free, it takes an explicit deriving newtype. See
arith-operator-traits-design.md D4.)
A generic opaque type's instance identity is its declaration identity together with its
instantiated arguments; the target answers only questions about runtime representation. Even when a
type parameter does not appear in the target at all, Phantom[Int] and Phantom[String] are two
types; substitution, equality, unification, display and export-surface validation all carry those
arguments along. "The representation is not public" does not imply "the type parameters are hidden".
The criterion for implementers (the alias-substitution test): replace
opaque type N = Tin place withalias N = T; if some function's answer changes, it is either one of the six things below, or it is a bug. Only six things are allowed to seeTyOpaque: the assignability and unification decision (who can convert), impl selection (head_of/impl_at), symbol naming (ty_key/dict_key/impl method names), the type name in diagnostics, export-surface visibility (§3.3: it checks the identity and the explicit arguments, not the representation), and witness resolution outside the allowlist (onlyEq/Hash/Ordfall back to the target). Every other function that eats aTy— width, descriptor, slot, boxing, which instruction, whether it can be a constant, whether some trait has an answer — takes the target's answer. The order is fixed too: ask about identity before representation.impl Eq[UserId]must come before "compare as Int", or declaring it would be pointless. The mechanised form is inscripts/opaque-twin/: every corpus program is run twice, once as written and once withaliassubstituted, and the outputs must agree (a compile error counts as output). Doing this by hand once on 2026-07-27 caught 12 places. Traits outside the allowlist (rendering, arithmetic and the rest) are outside that property because of the rule above; the corpus renders and computes through the target explicitly, and "the alias side compiles, the opaque side is refused" is pinned separately by thetwin-refused-alias-okverdict.
An opaque type can be given its own impls (impl Show[UserId], impl Display[UserId]), which take
precedence over the target type's; the orphan rule counts an opaque type as a local type of the
module that declares it. Char wrote both (impl Display[Char] and impl Show[Char], §1.5), so
it can be rendered, and neither of its renderings is the Int's while ==, < and hashing still
are.
scripts/opaque-twin/char.dawn pins all four, claim by claim, each rendering in both directions:
equal to the string its impl is defined to produce, and not equal to the Int's.
Why it is needed: before this, every time a representation had to be hidden a mechanism was hand-rolled on the spot —
Cursorwas an opaque scalar minted by the compiler, and the HAMT nodes from making the collections pure Dawn would have been the next one. This puts the mechanism up before there is a third time.
Cursorhas already been migrated (§11): the compiler's version was deleted outright andstd/cursorstands it back up using the mechanism of this section, the first real user of the feature.
Arrayis not the same kind of thing — this used to say it would "migrate along with making the collections pure Dawn", and measurement showed that was wrong: this mechanism needs a target type, andArrayhas no target, it is the representation; the two also point in opposite directions, in that this mechanism publishes the name and hides the representation, whileArray'sis_std_modulegate hides the name and exposes the representation to std. That gate stays; the reason is in `trait-v2-design.md` §8.3.
3.Declarations#
Only these are allowed at module top level: use, type (including opaque type), alias,
const, fn, test, trait, impl, effect. There is no top-level mutable state.
3.1Functions#
fn add(a: Int, b: Int) -> Int = a + b
fn greet(name: String) -> Unit !io = {
println("hi, ${name}")
}
- Parameter types must be written out in full (for every function); a
pub fnmust write the return type as well — a public signature is an API contract. - A private function may omit the return type:
fn double(x: Int) = x * 2. The body then determines the return type; if every effect annotation is omitted too, the base effect is determined on that same inference path. Three kinds of function must annotate the return type: (1)pub; (2) recursive / mutually recursive ones (the compiler infers in topological order over the call graph, and on a cycle there is nothing to infer from); (3) ones whose body usesreturnor?(both need a known return type). - The base effect is inferred only in the double-omission form: only a private function that
omits both its return type and every effect annotation has its return type and base effect
(pure /
!io) inferred from the body. Writing-> Twhile omitting the effect annotation is apurepromise, not a request for inference; performing IO in that body is an error. If the return type is omitted but an effect row is written, only the return type is inferred and the written row stays fixed. Named effects are never inferred and must be declared explicitly; once any named effect is written, the whole effect row is a fixed promise and io is not added automatically. For example, a function declared!Askthat also performs IO must write!(io | Ask);!Askalone is an error. - The function body is the single expression after
=; a block{ }is an expression too (§4.2). - Default parameter values exist (below); no varargs, no overloading by name: a name has exactly one signature in a scope. The traits behind the operators (§3.5, §4.3) are not overloading: each "trait × type" has at most one impl in the whole program, and choosing it is one lookup.
Default parameter values (2026-08-08, #207): a parameter may be written
name: Type = expr; a call that omits the argument evaluates the expression once per
call (not Python's evaluate-once-and-share):
fn column(kids: List[Int], align: Int = 0, gap: Int = 0) -> Int = align + gap * len(kids)
column(kids)
column(kids, gap: 12)
column(kids, align: 1, gap: 12)
- The default expression must be pure (neither
!ionor a named effect): otherwise the function's effect row would depend on whether the call site passes the argument — one signature, two rows, which a type system cannot have. If an earlier parameter is a function, a default may call it, on the same condition that the call is pure:fn app(g: fn(Int) -> Int, x: Int = g(1))is legal,fn run(g: fn() -> Int !e, x: Int = g())is refused (callinggbrings in the effect variablee). Evaluated in the declaring scope plus the parameters before it (2026-10-03, K6): it may refer to this module's private functions and
consts, and to the parameters declared before it, including an earlier parameter's own default (the Kotlin and JavaScript rule):fn find(s: String, sub: String, from: Cursor = start(s)) -> Option[Cursor] fn span(lo: Int, hi: Int = lo + 10, mid: Int = (lo + hi) / 2) -> IntReferring to the parameter itself or to one declared after it is a check-time error, each with its own message: "the default of parameter
arefers toaitself", "the default of parameterlorefers tohi, which is declared after it". The reason is evaluation order: a default is the parameter's value when the argument is omitted, so when it runs the parameter has no value yet, and the later ones have not been evaluated. A lambda in a default may capture earlier parameters (by value, as lambdas do); a local the defaultlets under the same name shadows as usual and is not a reference to the parameter. Before this rule (#207 until K6) a default could see no parameter at all; lifting that broke nothing: every old default is still legal and means the same.- Evaluation order: within one call, the written arguments all evaluate first, in
written order (§4.3, out-of-order named arguments included), then the omitted defaults
evaluate one by one in declaration order, each seeing the values of the parameters before
it — written arguments or earlier defaults, whatever order the named arguments were written
in. A tail block is a written argument, so it too evaluates before the defaults; it fills the
last parameter, after every defaulted one, so no default sees it. Defaults are pure, so the
order is only visible when a default panics (pinned on both backends by
scripts/spike-native/default_sees_params.dawn). - A parameter's type may mention the function's type parameters
(
fn join[T](xs: List[T] = []) -> T): the synthesizedf$default$krepeats the function's type parameters and their bounds verbatim, and a call that omits the argument calls it at that call's own instantiation — the same type substitution and the same witness dictionaries the call site already resolved. A default expression may therefore use the trait methods a bound provides (fn pick[T: Zero](v: T = zero())), and which implementation it gets is decided by the call site's instantiation. - A default need not sit at the tail:
fn f(a: Int = 1, b: Int)is legal —acan only be skipped by namingb(the positional prefix takes slots left to right and cannot jump). A skipped required parameter reports "missing argument(s) forf: …"; a count outside the range reports "ftakes 1 to 3 argument(s), got 0". - Implementation shape: each defaulted parameter synthesizes a pure function
f$default$kin the declaring module ($is not in the identifier lexicon, so source code cannot spell it) whose parameters aref's first k parameters — whether or not the default reads them; the arity follows from the signature alone, so a call site in another module knows how to call it fromf's signature. A call that omits an argument first binds the written arguments to locals in written order, then binds each omitted default to a local in declaration order (f$default$ktakes the first k locals), and finally callsfin parameter order; when only the first parameter is omitted,f$default$0takes nothing and the call binds nothing. Both backends and the comptime interpreter treatf$default$kas an ordinary top-level function and the bindings as ordinarylets, with no special handling. caller()is the one default not evaluated at the declaration (2026-10-05, L4): when a parameter's default is exactlycaller(), a call that omits the argument gets its own location (aLoc, §8.4), and nof$default$kis synthesized.caller()anywhere else (wrapped inside a default, in a body, in aconst) is a compile error. It is a constant, so it satisfies "a default must be pure".- Using the function as a value loses the defaults: after
let g = f,g's type is the fullfn(A, B, C) -> R— the same discipline as "a bare constructor used as a function value loses its field names" (§2.3). - Scope: top-level functions only (private and module functions included). Local
fn(its calls go through the function-value path), lambdas, constructor fields (records already have{ ..base, x: v }), trait methods (Dawn already has a thing called "default" — the default method body; and under dictionary passing an impl could not override a value default), effect operations (an operation call reads an evidence field and calls a closure; there is no symbol to hang one on) and Java methods take none, each with its own refusal.
Local named functions: a block may contain a fn name(params) -> T [!Row] = body statement —
essentially "a lambda whose name is visible inside its own body", so it can recurse (a self
tail call compiles to a loop, §12.4), can capture enclosing bindings (by value, same rule as
lambdas), and can be passed as a value. Parameter types and the return type must be written out
in full; the effect row follows the rule of a written function type (§6.2, rule 8): !io, named
effects, and the effect variables and associated-effect projections the enclosing signature binds
may be written, but no new effect variable can be introduced; type parameters cannot be declared
(the enclosing function's type parameters are naturally in scope).
fn sum(xs: List[Int]) -> Int = {
fn go(i: Int, acc: Int) -> Int =
if i == len(xs) { acc } else { go(i + 1, acc + xs[i]) }
go(0, 0)
}
3.2Constants#
const MAX_DEPTH: Int = 64
const SIN_TABLE: List[Float] = comptime {
range(0, 360) |> map(d => sin(to_radians(d)))
}
The right-hand side of a top-level const is implicitly in a comptime context (§7); it must be
pure and reducible to a constant.
3.3Visibility#
All declarations are module-private by default; pub exports. pub can be used on fn, type,
alias, const, trait, effect. pub type exports its constructors and fields as well.
The complete resolved export surface of a pub declaration must be nameable outside its module.
Marking only the outermost declaration pub does not export the private nominal identities it
refers to:
- A
pub fnchecks its type parameters' bounds, its parameters, its return type and its complete effect row; apub constchecks its declared type, while the initializer stays an implementation detail. - A
pub typeor record checks every constructor field; apub traitchecks every method signature, constraint and effect row, while a default body stays a private implementation detail; apub effectchecks every operation's parameters and return type. - A transparent alias is fully expanded at this boundary: a private alias whose ultimate target is
entirely public may appear in a public surface, but reaching a private nominal type after
expansion is still an error. A
pub opaque typeis the opposite — it stops at its own public identity, so the representation may use private types, but every instantiated argument in the public spelling must still be publicly nameable, and a private opaque identity may not appear in a public surface at all. - A type projection checks both its trait identity and its subject; a named effect must be a
pub effect. - An impl has no
pubspelling: only an impl of a public trait for a subject nameable outside the module belongs to the export surface, and then its generic constraints and every associated type binding must be publicly nameable too. An impl of a private trait, or for a private subject, is not reachable from outside and so is not constrained here — which is also whydawn docdoes not list it.
An ordinary pub fn may declare a public named effect (§6.5): callers can import it, propagate
it, or install a handler. It is a private effect in a public surface that is an error.
3.4Test blocks#
test "precedence" {
assert eval("2+3*4") == Ok(14)
}
test eval {
assert eval("1") == Ok(1)
}
testblocks are compiled and run only bydawn test;dawn buildstrips them.!iois allowed inside the block.assert expr:expris aBool; on failure it reports the source text and the values of the sub-expressions on either side (the compiler takes==and the comparison operators apart to give a good failure message), and ends with the assertion's location (§8.2).
Tests named by a declaration. Instead of a string, test may be followed by the name of a top-level
declaration of this module, and the test is that declaration's example:
- There are three spellings:
test f(a function),test T(a type, type alias, trait or effect; a constant's name also starts with a capital, sotest LIMIT), andtest T.Ctor(the constructorCtorof typeT). No paths (test a.b,test T.C.D), and a bare capitalized name always means a top-level declaration, never a constructor (a record and its constructor share a name, so a bare one would have two readings). - The name may only refer to a top-level declaration of this module (private ones included) or to one
of its constructors. One that resolves to nothing is a compile error, reported on the name after
testwith the reason: nothing in this module is declared with that name, the name is imported from another module, the type has no such constructor, the name before the dot is not a type, or the name is two declarations of this module. An imported name does not count: an example belongs to the module that declares it. - A declaration may have several examples.
dawn testreports them asexample f; the second and third with the same name, in the order written, asexample f #2andexample f #3. - Otherwise it is the same as a test named by a string: the same scope (it sees this module's private
declarations), run by
dawn test, stripped bydawn build. dawn docpublishes the examples ofpubdeclarations and of their constructors: afterdoc(andlinks) the object gains anexamplesarray holding, in the order written, the source of each test body without its outer braces. A body written on one line loses its leading and trailing whitespace; a body over several lines loses the line of the{and the line of the}when nothing but the brace is on them, the indentation all of its non-blank lines share and every line's trailing whitespace, while blank lines stay blank and comments are kept. An object with no example has no such key; examples of private declarations are not published.
Rationale: an example should be code, not text inside a comment. Written as a test, dawn fmt, the
editor and diagnostics all work on it as usual, renaming or deleting the declaration it names is
immediately a compile error, and a published example therefore always compiles and passes. The shape is
Zig's decltest rather than Rust's code blocks in /// that a tool extracts and runs: those need a
language of block attributes of their own (ignore, no_run, should_panic), mapping diagnostics from
the comment's text back to source positions, and a tool that rebuilds code across lines, against §1.2's
"every comment line tokenizes on its own". The name may only refer to this module: a test block already
belongs to its module, so naming a declaration elsewhere means the example is in the wrong place, and
if imported names counted, the same example would change owners as use lines come and go. A
constructor is T.Ctor rather than a bare Ctor so that "a bare name is a top-level declaration" has
no exceptions.
3.5trait and impl#
A single-parameter, nominal typeclass, implemented by dictionary passing (the full design and the decision rules are in trait.md):
trait Ord2[T] {
fn cmp2(a: T, b: T) -> Int
fn max_of(a: T, b: T) -> T = if cmp2(a, b) >= 0 { a } else { b } # default body
}
impl Ord2[Point] {
fn cmp2(a: Point, b: Point) -> Int = a.x - b.x
}
fn sort2[T: Ord2](xs: List[T]) -> List[T] = ... # bound: [T: Trait (+ Trait)*]
- A trait has exactly one type parameter; its methods enter the module function namespace (callable directly, by UFCS, or in a pipeline).
- Injection is a per-trait property: whether a trait's method names occupy the function
namespace is decided by that trait. Today a
traitdeclaration always injects, and the six built-in traitsOrd/Eq/Hash/Show/Iter/Leninject too; the twelve whose method name the language consumes on the user's behalf do not, and their method names appear only in impl bodies, in documentation and in error messages:Index(consumed by[], §4.8),Display(consumed byto_stringand${...}, §4.3), the six arithmetic traitsAdd/Sub/Mul/Div/Rem/Neg(consumed by the operators, §4.3),FromInt/FromFloat(consumed by numeric literals, §1.5) andStagedIter/StagedVar(consumed by the stagedfor, §4.7). - Eighteen built-in traits:
Ord(cmp, behind ordering beyond</<=),Eq(eq, behind==/!=),Hash(hash),Show(show, the nested rendering, and the boundto_stringasks for),Iter(behindfor..in, §4.7),Len(len, the number of elements a container holds),Index(behind[], §4.8),Display(display, the top-level rendering, behindto_stringand${...}, §4.3), the six arithmetic traits behind+ - * / %and unary-(see their entry below),FromInt/FromFloatbehind numeric literals (the entry after that), andStagedIter/StagedVarbehind the stagedfor(the entry after that). The impls for the scalars ship with the language;derive Ord/derive Showcast an ordinary impl, and on a generic type a conditional impl;Displaycannot be derived (the reason is underDisplaybelow). A tuple has no head, so no impl can be written for it; the first four are synthesised structurally for tuples by the compiler. deriveis a closed table: onlyOrdandShowcan be derived, the table is written in exactly one place in the compiler (types.derivable_traits), any other name in aderivelist is a compile error, and the diagnostic lists the table's entries.Eq/Hashare not in the table because they need no request: every type has a structuralEq/Hash(§4.3 and theEq/Hashentry below), and an impl overrides it (as Go's struct==does);Showhas to be requested because rendering prints the representation, which is a presentation decision, while equality and hashing are not (the same reason applies to opaque types, §2.7). A user trait cannot be derived: a derive needs the compiler to know the trait's structural rule, and Dawn has no compile-time metaprogramming for a library to state one (Haskell 2010's deriving likewise only accepts a closed set from the Prelude).Iterdeclares two associated types and four methods (associated types are covered further down this section):trait Iter[C] { type Cur type Item fn iter_start(it: C) -> C.Cur fn iter_done(it: C, c: C.Cur) -> Bool fn iter_next(it: C, c: C.Cur) -> C.Cur fn iter_get(it: C, c: C.Cur) -> C.Item }— four cursor methods rather than anextthat returns a pair, so that one step forces no allocation (the shape of the cursor is up to the impl). std provides one impl each forList/String/Bytes/Map/Set(the elements areT/ a single-characterString/ anIntbyte /(K, V)/Trespectively); a user type that implementsItercan be iterated byfor. The four method names are injected into the function namespace along with the prelude, and get the same treatment as the rest of the prelude names — they can be shadowed by a declaration in this module (§10.3). The parameter names come from this declaration (named arguments, §4.3): the receiver isitand the cursor isc, so a caller writesxs.iter_done(c: k).Lendeclares one method and no associated types:trait Len[C] { fn len(x: C) -> Int }.len(c)andc.len()find the impl by the receiver's type; the impl forListships with std, and a user type getslenby writingimpl Len[T]. The namelenis injected with the prelude (the entry above), but anfn leninside an impl body is not a top-level declaration of the module and shadows nothing, which is the point of it: a module may writelenfor its own type whilelen(xs)on aListin the same module keeps working. At aList,Lenis the language'slenprimitive, not a call on the impl's method; the impl body serves only the dictionary slot of a[C: Len]bound, and calls an internal name (calling itself is unconditional self-recursion, which the checker rejects).Indexdeclares two associated types and one method:trait Index[C] { type Idx type Item fn index(c: C, i: C.Idx) -> C.Item }. The language provides impls forList(Idx = Int) andMap(Idx = key type); a user type that implementsIndexcan use[](example in §4.8). The method nameindexdoes not enter the function namespace (see the injection rule above), and appears only in impl bodies, in documentation and in error messages. A type has exactly one index type —Indexis a single-parameter trait, so a second kind of subscript on the same container (slicing, say) cannot be written.Displaydeclares one method and no associated types:trait Display[T] { fn display(x: T) -> String }. It is the top-level rendering layer:to_string(x)and"${x}"use the impl when there is one, and otherwise render by the existing rules of §4.3. The method namedisplaydoes not enter the function namespace (see the injection rule above). Three boundaries:Show[T]remains the boundto_stringasks for.Displayonly refines an answer that already exists; nothing becomes renderable that was not.- It cannot be derived.
derive Showsays "render my structure", while aDisplayis a decision about presentation: one per type, hand written. - Only the type itself is asked (§4.3): with no
Displayonopaque type A = B,Auses its ownShowand does not borrowB'sDisplay(an opaque type inherits no rendering, §2.7).
The one impl that ships with the language is
impl Display[Char](instd/char, §1.5; the same module writes the other layer'simpl Show[Char]).Add/Sub/Mul/Div/Rem/Neg(behind+, binary-,*,/,%and unary-, §4.3): homogeneous, one type parameter,trait Add[T] { effect Add = !() fn add(a: T, b: T) -> T !T.Add }, the others alike (Negis unary:fn neg(a: T) -> T !T.Neg). The language provides impls forInt/Float; the method names do not enter the function namespace. Each trait carries one associated effect named like the trait, pure by default; an impl may bind it to a named effect (effect Add = !Dev), and that binding is the operator's effect, recorded in the enclosing signature (§6), while generic code projects it as!T.Add. An impl's effect binding can only be a ground row, so a conditional impl that forwards arithmetic to its type parameter (impl[T: Add] Add[Pair[T]]withp.a + q.ain its body) cannot be written; a conditional impl that does not forward is unaffected. The six names are taken in trait and effect position from now on: a user can no longer declare atraitor aneffectby one of them (atypestill can). There is noNum/Zero/One.FromInt/FromFloat(behind numeric literals, §1.5):trait FromInt[T] { effect FromInt = !() fn from_int(n: Int) -> T !T.FromInt }, andFromFloatalike (fn from_float(x: Float) -> T). The subject is only in the return position, and that a method of that shape cannot be called by name does not matter: the method names are not injected, and the compiler inserts the call at aTit already knows and resolves the witness directly. The language providesFromInt[Int],FromInt[Float](a panic when not exact, the compile-time rule of §1.5) andFromFloat[Float], and noFromFloat[Int]. In generic code a0under[T: FromInt]isfrom_int(0). Both names are taken in trait and effect position from now on as well.StagedIterandStagedVar(behind the stagedfor, §4.7):trait StagedIter[R] { type Item effect StagedIter = !() fn staged_for(r: R, body: fn(R.Item) -> Unit !R.StagedIter) -> Unit !R.StagedIter },trait StagedVar[T] { type Cell effect StagedVar = !() fn var_open(v: T) -> T.Cell !T.StagedVar fn var_get(c: T.Cell) -> T !T.StagedVar fn var_set(c: T.Cell, v: T) -> Unit !T.StagedVar }. The language consumes both traits' method names, so they do not enter the function namespace. Neither can be derived. Each has one associated effect named like the trait and pure by default, as the arithmetic and literal traits have, so generic code over a type that implements both writes!T.StagedIterand!T.StagedVarwithout ambiguity. The language provides no impl of either for any type. Both names are taken in trait and effect position from now on as well.- Coherence: at most one impl per "trait × type" across the whole program; the orphan
rule: an impl can only be written in the module that declares the trait or the subject type.
Impls take effect globally, no
useneeded. - Subject shape: one type constructor, applied to pairwise distinct type variables, and
those variables are exactly this impl's own parameters —
impl Eq[Money],impl[T: Eq] Eq[List[T]],impl[K: Eq, V: Eq] Eq[Map[K, V]]are all legal;impl Eq[List[Int]](a concrete argument) andimpl[T] Eq[Map[T, T]](a repeated variable) are not. This one rule decides "which impl matches" (heads are equal), "whether two impls overlap" (the same), and "whether recursive solving terminates" (a subgoal is a proper subterm of its parent goal). - Conditional impls: the methods of
impl[T: Eq] Eq[List[T]]are generic functions that take dictionaries according to their bounds; a concrete target likeEq[List[Int]]is solved to a constant dictionary at compile time, whileEq[List[T]](withTrigid) is built at run time fromEq[T]. No dyn (for heterogeneous collections see "Heterogeneous collections" at the end of this section; the reasoning behind the verdict and the conditions for reopening it are in trait.md §10), no supertraits, no specialisation (no asking "which one is more specific"). A trait method's effect row is a full row: pure,!io, effect variables, named labels and associated-effect projections are all admitted (§6.5 Boundaries); an impl's effect ⊑ the trait declaration's (after reduction through that impl's bindings), the two sides' effect variables are paired by position, and the labels must match exactly on both sides — each label is one hidden evidence parameter, part of the method's shape. Associated types (design and verdicts in assoc-types-design.md): a trait body may declare a bare
type Itemmember (no bound, no default), and an impl binds each declaration exactly once withtype Item = X(X resolves in the impl's own parameter scope, and must not itself be a projection); a missing binding, multiple bindings, and binding a name the trait does not have are each a compile error. A method signature projects it asT.Item— the subject must be a type parameter carrying that trait bound (exactly one bound declares the name; zero and ambiguity are both errors), and a concrete subject such asList[Int].Itemis not supported.- Reduction is eager: when instantiation at the call site lands the subject on a concrete
head, the projection is immediately replaced by the bound value via that subject's impl (the
same door as dictionary selection) — an unreduced projection does not exist in a concrete
type. A projection on a rigid subject (a
Tinside a generic body) stays as it is, and unifies only with a literally identical projection. - An associated type name lives only inside the trait's scope; it does not enter the module
type namespace, and
T.Itemis the only way to reach it. An opaque subject looks only in its own impls and does not fall back to the target's (same rule as witnesses: onlyEq/Hash/Ordfall back, and a relation has no associated types). Zero representation at run time: types are erased as usual, and a dictionary gains no extra slot. - A projection in an argument position takes no part in inference: the type parameters are
fixed by the non-projection argument positions; the projection is then reduced through
that subject's impl binding and compared against the actual argument. Corollary: a method
whose type parameter occurs only inside a projection cannot be called by name (it reports
"cannot infer"); trait authors should put the subject in the parameter list — all four
Itermethods putc: Cin position 0 for exactly this reason.
- Reduction is eager: when instantiation at the call site lands the subject on a concrete
head, the projection is immediately replaced by the bound value via that subject's impl (the
same door as dictionary selection) — an unreduced projection does not exist in a concrete
type. A projection on a rigid subject (a
trait Head[C] {
type Item
fn first(c: C) -> Option[C.Item]
}
impl[T] Head[List[T]] {
type Item = T
fn first(c: List[T]) -> Option[T] = get(c, 0)
}
fn head_or[C: Head](c: C, d: C.Item) -> C.Item = # the projection reduces at instantiation
match first(c) { Some(x) -> x, None -> d } # head_or([1], 9) is Int
Associated effects (design and verdicts in effect-params-design.md, knife 5; defaults ruled 2026-08-26): a trait body may declare an
effect Emember (no bound) and may give it a default roweffect E = !X; an impl binds each declaration at most once witheffect E = !X, whose right side is a concrete row: one named effect,!io, or the empty row!(); effect variables and projections cannot be bound, and a default's right side follows the same discipline. An impl that omits a defaulted member takes the default; one that writes a binding overrides it. Omitting a member without a default, multiple bindings, and binding a name the trait does not have are each a compile error. Registration materializes the default into that impl's binding, after which reduction, evidence and the backends cannot tell a defaulted binding from a written one (the five pure tea impls compile to byte-identical Core with their bindings deleted, which is that sentence's executable verdict). A method's row projects it as!T.E— the subject must be a type parameter carrying that trait bound (exactly one bound declares the name; zero and ambiguity are both errors), and the licensed positions are the type projection's: inside a method signature, including function-type rows in parameter and return positions.- Reduction is eager, through the same door as the type projection: when instantiation at the call site lands the subject on a concrete head, the projection is immediately replaced by the bound row via that subject's impl (the same answer as dictionary selection) — an unreduced projection does not exist in a concrete row.
- Evidence settles per parameter, exactly one slot: each projection written in a row adds
one erased hidden evidence parameter, after the evidence the written labels synthesise.
A call site whose reduction yields a label hands over that label's evidence; one that lands
on its own still-rigid projection forwards its own slot; one that reduces to pure or
!iohands over a placeholder. The dictionary itself carries no evidence; a slot's shape depends on the trait declaration alone. v1 boundary: one associated effect member binds at most one label — two effects want two members. - A bound is a check, not a definition: a projection always reads the impl's actual binding (unique per subject); a row written on a consumer's bound is only an upper bound (the impl's side ⊑ it), and the two coexist.
The built-in
trait Ord[T] { fn cmp(a: T, b: T) -> Int }and the impls forInt/Bool/String/Bytes/Unit(Unit'scmpis always0, §2.1;Floathas none: under NaN there is no total order to give — the reasons for refusing are in §4.3, numeric edge semantics; the one previously given followingDouble.comparewas reverted on 2026-07-26).Ord[Bool]:false < true, that is, the two constructors in declaration order (the same rulederive Ordapplies to a sum type; Rust, Haskell and OCaml use the same order).Ord[Bytes]: unsigned byte-by-byte lexicographic order; the first differing byte decides, and on an equal common prefix the shorter is smaller (as in Rust's[u8]and Haskell'sByteString). This is exactly the algorithm ofOrd[String]over UTF-8.- These two were added on 2026-09-24 (builtin-privileges-design.md
§1); before that
Bool/Byteshad==but no<, could not be sorted, and a user had nowhere to write either impl (the orphan rule). cmppromises only the sign: less than returns-1, equal returns0, greater returns1, and no other magnitude is promised —cmp("a", "z")used to leak the host's code unit difference-25; since 2026-07-31 every type uniformly returns −1/0/1 (treating the result as a difference to be scaled could overflow anyway, and std has always used only the sign).Ord[String]is code point order: compare code point by code point, the first differing code point decides, and when one is a prefix of the other the shorter is smaller. UTF-8's byte order is equivalent to code point order (a design property of the encoding); the currency of hashing is still UTF-16 code units (see the hash leaves below), and it deliberately does not follow — a hash only has to agree with==, it does not have to share a currency with the order. Everywhere else in the language (len/slice/chars/code_points/cursors) the currency was already code points; the order was the last place to be aligned (2026-07-31; before that it was UTF-16 code unit order, observable only on supplementary plane characters).
derive Ordgenerates a field-lexicographic comparison (a sum type compares constructor declaration order first, and when the constructors differ it produces only-1/1), and the fields must be a scalar withOrd, a type with an Ord impl of its own, or a type parameter of the type itself (in which case what is generated is a conditional impl:type Box[T] = { v: T } derive Ordgivesimpl[T: Ord] Ord[Box[T]]).List[T]has a lexicographic impl written in std.The built-in
trait Eq[T] { fn eq(a: T, b: T) -> Bool }andtrait Hash[T] { fn hash(x: T) -> Int }.Eq's scalar impls areInt/Float/Bool/String/Bytes/Unit;Hashhas noFloat(the reason for refusing it is under numeric edge semantics in §4.3, as forOrd[Float]), so it has five. There is noderive Eq:==is already structural for every type (§4.3), which amounts to every type implementing Eq implicitly; you write an impl in order to override it.- After an override,
==is that impl, and container and nested comparisons follow along. impl Eqandimpl Hashmust appear as a pair (a compile error otherwise): equal values must hash the same, or the type is useless as aMap/Setkey.- Inside the overriding body you cannot use
==on the subject type itself — that is this impl, and it would recurse forever; compare the fields. - When an
Eq/Hashbound is instantiated at a type that has no impl (an ADT, a tuple), the compiler synthesises an implementation from that type's own structure — equivalent to an implicit conditional impl, requiring the corresponding bound on the type parameter when the subject contains one. When the subject is statically known it collapses straight to a primitive and no dictionary is built. - The synthesised hash is an observable number (
hash(x)can be printed), so it is defined here: seed1, thenh = 31*h + hash(part)part by part, in 32-bit wrapping arithmetic; tuples in element order, constructors in field order, and when there is more than one constructor the constructor's ordinal is folded in first as the first part (with a single constructor there is no distinguishable tag — the same rule as==/cmp). The algorithms for the five scalar leaves are likewise defined here (all 32-bit wrapping arithmetic, the result a 32-bit number):
Unit:1, the seed of the compound rule above (there are no parts).Int:v ^ (v >>> 32), take the low 32 bits.Bool:truegives1231,falsegives1237.String: seed0, thenh = 31*h + unitper UTF-16 code unit. The currency of hashing is UTF-16 code units, which is a different matter from the currency of the order, and that is deliberate: a hash only has to agree with==(same content ⟹ same hash), it does not have to share a currency with any order, and when the order's currency changes the hash does not follow.Bytes: seed1, thenh = 31*h + bbyte by byte, withbthe signed byte (−128..127). Exactly the same shape as the composite rule above — fold each byte as one part.
Listis not in this list: std writesimpl[T: Eq] Eq[List[T]]and the correspondingHash/Ord.Map/Set/tuples still go through synthesis.
- After an override,
- Trait methods and functions with bounds can be used as function values directly: once the
expected function type settles the subject, the compiler writes the eta expansion and the
bound is discharged to a dictionary there. When the bound belongs to the enclosing
function's type parameter, the synthesised closure captures that function's dictionary
parameter, exactly as a handwritten lambda does. A subject nothing settles (
let f = to_string) still reports "cannot infer the type parameter(s)"; write the expected type, or an annotated lambda. A function with an effect label can be a value all the same: the label enters the value's type and the call site supplies the evidence (§6.5, boundary). An effect operation cannot; there is no function symbol to take. - Limits: calls with trait bounds and impl-based sorting are not allowed in comptime.
Heterogeneous collections#
There is no dyn; the idiom for a heterogeneous collection is first-class functions plus an opaque type: while the type is still concrete, wrap the method call in a closure, put the closure in a record, declare the record as an opaque type, and give that opaque type an impl.
type ShownRepr = { render: fn() -> String }
pub opaque type Shown = ShownRepr
pub fn shown[T: Show](x: T) -> Shown = {
let r: ShownRepr = ShownRepr { render: () => show(x) }
r
}
impl Show[Shown] {
fn show(s: Shown) -> String = {
let r: ShownRepr = s
r.render()
}
}
pub fn main() -> Unit !io = {
let xs: List[Shown] = [shown(1), shown("two"), shown(true)]
for x in xs { println("${x}") }
}
1
"two"
true
Each of the four steps rests on a rule stated in this document:
- The dictionary that discharges
Show[T]insideshownis an ordinary value, captured by the lambda by value (this section, "Trait methods and functions with bounds can be used as function values directly": exactly as a handwritten lambda does; §4.5: a closure captures bindings by value).Tends there; it no longer appears inShown. Shownis an opaque type, so at runtime it isShownRepr, at zero cost (§2.7).impl Show[Shown]is legal and takes precedence over the target type's impl: the orphan rule counts an opaque type as a local type of the module that declares it (§2.7).let r: ShownRepr = scannot be dropped: an opaque type converts only at assignment, argument and return positions, not inside an expression (§2.7), sos.render()is a compile error.
Where it applies:
- A single-method trait: a complete replacement.
- A multi-method trait: one field per method, i.e. a hand-written function table; the language does not write it for you.
Eq/Ordcannot be written this way: a binary method (fn eq(a: T, b: T)) requires both arguments to have the same type, while twoShownvalues may have different underlying types — "the same type" has nowhere to live in the type ofShown. Corollary: nothing likeMap[Shown, V]can be built.
4.Expressions#
Dawn is expression-oriented: if, match and blocks all produce values.
4.1Bindings#
let x = 42 # immutable binding, type inferred
let y: Float = 1.0 # optional annotation
var acc = 0 # mutable local variable
acc = acc + 1 # assignment, legal only for var
letis immutable and cannot be shadowed (binding the same name twice in one scope is an error; a nested scope may shadow).varis limited to local variables inside a function body; record fields, parameters and top-level bindings are all immutable.- Assignment is a statement (type
Unit), not an expression — theif (x = 1)class of mistake does not exist.
4.2Blocks#
let area = {
let w = 3.0
let h = 4.0
w * h # the last expression is the block's value
}
A block introduces a new scope; the last expression is the block's value, and every other
statement must have type Unit (this stops a Result from being quietly discarded —
discarding a non-Unit value must be written out as let _ = ...).
4.3Operators and precedence#
From lowest to highest:
| Precedence | Operator | Associativity | Notes |
|---|---|---|---|
| 1 | |> | left | pipeline, see §4.4 |
| 2 | || | left | logical or, short-circuiting |
| 3 | && | left | logical and, short-circuiting |
| 4 | == != < <= > >= | non-associative | comparison; chained comparison is a syntax error |
| 5 | | | left | bitwise or; Int only |
| 6 | ^ | left | bitwise xor; Int only |
| 7 | & | left | bitwise and; Int only |
| 8 | << >> >>> | left | shifts; Int only. >> is arithmetic (sign-filling), >>> logical (zero-filling) |
| 9 | ++ | right | String/List concatenation |
| 10 | + - | left | both sides the same type; native on Int/Float, through Add/Sub on other types (§3.5) |
| 11 | * / % | left | both sides the same type; native on Int/Float, through Mul/Div/Rem otherwise; Int division by zero panics |
| 12 | not, unary -, ~ | prefix | unary - goes through Neg past Int/Float; ~ is bitwise complement, Int only |
| 13 | ? . () [] call | postfix | ? see §8.1; () has been a general postfix since 2026-07-30 (see below) |
- A call is a general postfix (SYN-02): any postfix expression followed on the same line
by
(is an application —make()(1),(if c { f } else { g })(1),get_handler()(req)are all legal.f(x)andCircle(1.0)take the same postfix path (since 2026-07-31 neither has a special case of its own); which kind of call it is is decided by what the callee is, not by how it is written: a constructor name → construction, a function name → static call (dynamic call if the name is bound to a function value), a Java member → Java call, any other expression → applying a function value. A(on the next line is not eaten: a newline ends the postfix chain (only.may continue a chain across lines, §1.7), solet x = ffollowed by(1 + 2)on the next line is still two statements — the same discipline as unary-not continuing a line. Tail block: a bare
{ ... }following on the same line is the last argument (2026-08-08, #206).f(a) { e }isf(a, () => e); a parameterised block spells its head in the braces —items(xs) { item => e }isitems(xs, item => e), and multiple or annotated parameters use the parenthesised list:fold(xs, 0) { (acc, x) => e },{ (x: Int) => e },{ () => e }. The landing point is exactly the one(has (soxs.each { e }yields a method call and a bare namecolumn { e }an application); the sugar changes the spelling, not the node. The historical ruling — "a bare{}cannot be told apart from the body of a parenthesis-freeifheader, permanently out of scope" — was overturned (2026-08-08): the distinction is the same switch that keeps a record literal out of a header (nb, below), and the ruling predates it. Four side conditions:- Same line only, the same discipline as
(:let y = g(1)followed by{ ... }on the next line is two statements; a free-standing block statement is not swallowed by the call on the line above. if/while/forheaders and amatchscrutinee open no tail block (nor does awithcallee, §4.10): inif f(x) { ... } { ... }the first braces are theif's own body. Parentheses restore it —if (f(x) { ... }) { ... }. A match arm's guard is not restricted: a guard is followed by->, not a block, so there is no body to swallow.- Refused when the previous token is
}:if c { 1 } else { 2 } { 3 },match x { ... } { ... },P { x: 1 } { ... }andf(a) { ... } { ... }do not mean applying a block to a value; and "one call takes at most one tail block" holds without a rule of its own. To apply a block to a value, parenthesise it first:(...) { ... }. - An uppercase name's braces belong to the record literal (§2.3):
Column { ... }is a construction, so a tail block never lands on a bare TYPEIDENT — component functions are lowercase.Column(align: c) { ... }is not affected: the parentheses come first, and the block attaches as usual.
In tail position the block binds parameters: the head of
{ x => ... }is its own production, not a block being reinterpreted (the wayPoint { x }uses braces without being a block). To write "a block whose value is a lambda", parenthesise:f(a) { (x => e) }— the group no longer closes with=>, so no head is read. The tail block fills the last declared parameter/field (Kotlin's rule), whatever named arguments came before it — "positional cannot follow named" does not apply to it — and defaults fill the remaining holes (#207): incolumn(gap: 12) { text("hi") }the block fillsbodyandaligntakes its default. If that parameter is already filled the report is "the last parameterbodyis already given; the tail block is what fills it".- Same line only, the same discipline as
- The tail block is the only trailing-argument form (#206 phase 2): the transitional
f(a) fn(x) => ehas retired, and the current spelling is onlyf(a) { x => e }.fnhas no expression position; it is used only by named function declarations (§3.1) and function types (§2.2). The old trailing-closure spelling gets a dedicated migration diagnostic. A bare arrow lambda still cannot take the tail position: inf(a) (x) => ethe(x)is already the curried callf(a)(x), and the two readings cannot be told apart before the=>— that argument is about(, not braces, and it stands. - Named arguments (
f(a: 1)) are syntactically legal at every call position, dot calls and applied function values included; whether they are usable is decided by what the callee is (2026-08-08, #207). A callee with a signature accepts them: top-level functions, module functions, UFCS/dot calls, pipes, trait methods (the names are the trait declaration's parameter names; an impl's own spellings are purely local), effect operations, and constructors (field names, see §2.3). A function value does not — a function type carries no parameter names, so afterlet g = f,g(a: 1)is a type error ("gis a function value, and a function type carries no parameter names"). A Java member does not — Java metadata has no parameter names. The slotting rule is the same one constructors use: the positional prefix takes slots left to right, a name takes the slot it names, names may come in any order, and a positional argument may not follow a named one; a dot call's receiver has already taken the first slot positionally, sox.f(self: y)reports "parameterselfis given twice".f(1)(2)parses, and the meaning is still "the result off(1)must be a function" — Dawn has no automatic currying; the second application's callee is a function value, so it cannot take names. - Arguments evaluate in written order (2026-08-08, #207 ruling): call and constructor
arguments evaluate in the order they are written (as in Python / C# / Scala / Swift /
Kotlin); named arguments reorder the slotting, not the evaluation — in
R { b: f(), a: g() },f()runs beforeg()and each value lands on its own field. Omitted defaults evaluate after every written argument, in declaration order (§3.1)...basecan only be written first, and evaluates first. Binary operators evaluate left to right (&&/||still short-circuit). Before this rule, constructors evaluated in declaration order and the C backend left argument order to C (gcc goes right to left) — both are fixed to this rule, pinned by the two-backend differential (scripts/spike-native/named_args.dawn,eval_order.dawn). - Bitwise
& ^ \|and the shifts act onIntonly (there is noFloatbit pattern); they bind tighter than comparison, soa & b == cis(a & b) == c, without the C-family trap. A shift count takes its low 6 bits (as JVMLSHLdoes).
Numeric edge semantics (all of them guaranteed — after selfhosting the two implementations are cross-checked against this, "happens to agree" is not allowed):
Intoverflow wraps around (two's complement, as on the JVM):MAX + 1 == MIN. The one division that can overflow,MIN / -1, wraps too (the result isMIN), andMIN % -1 == 0./truncates toward zero (-7 / 2 == -3, not floor); the sign of%follows the dividend (-7 % 2 == -1,7 % -2 == 1), anda == (a / b) * b + a % balways holds.- Division by zero with
/and%is a panic (messageInt division by zero/Int modulo by zero) — a panic and not a Java exception, socatch_faultdoes not catch it and onlycatch_panicdoes (§9.8). Floatarithmetic follows IEEE 754: division by zero does not panic (you get±Inf/NaN);==and< <= > >=are IEEE comparisons — NaN compares false against any value, itself included, and-0.0 == 0.0is true.Floathas noOrd(changed 2026-07-26; before that it was given a total order following Java'sDouble.compare). NaN is incomparable with any value, itself included, so there is no total order to give on Float — andOrdis exactly whatsort/max/min/max_by/min_by/derive Ordand[T: Ord]depend on. "Degrading to a partial order" in Dawn means one impl fewer, not one trait more:</<=/>/>=on scalars do not resolve a witness in the first place, and stay usable with the same IEEE semantics as before. Exactly one thing changed:[T: Ord]no longer admits Float, andcmp(1.5, 2.5)no longer compiles. (Rust'stotal_cmpis an inherent method onf64, andf64likewise has noOrdimpl.)Floathas noHashand therefore cannot be a Map/Set key:-0.0 == 0.0is true while the two bit patterns differ, and no hash can be consistent with that equality and with itself at the same time (as in Rust).FloathasEq, and it is not reflexive (nan == nanis false). That is a fact about IEEE, not a hole, and Dawn does not split outPartialEq/Eqas two traits because of it (an explicit verdict on 2026-07-26 not to split): Dawn has oneEqonly, and splitting would make an everyday expression like1.5 == 2.5carry one more concept, a cost far larger than the benefit. Where reflexivity is relied on (container lookup), the rule that Float cannot be a key already blocks it.The
to_string/Showrendering ofFloatis a rule the language writes down itself (the implementation is one piece of Dawn source,std/fmt.dtoa, shared by both backends and by comptime folding; since 2026-07-31 it no longer calls a host method — the shape of the rule was taken from that day's JVMDouble.toString(the Schubfach shortest round-trip of JDK 19+), so not one byte of the published JVM output changes, but if the host switches algorithm from now on Dawn does not follow):- Shortest round-trip: the output is the shortest decimal digit string that reads back as the original double uniquely; among candidates of the same length the one closest to the true value wins, and a remaining tie goes to the even significand.
- Two forms, switched on magnitude:
10^-3 ≤ |v| < 10^7uses the ordinary decimal form (0.001,9999999.0), everything else scientific notationd.dddEe— exactly one non-zero digit first, at least one digit after the point, an upper-caseE, no leading zero in the exponent, a-on a negative exponent and no+on a positive one (1.0E7,9.999999999999998E-4,4.9E-324). - An integral value keeps its
.0(1.0, not1); the fractional part has at least one digit;-0.0keeps its sign. - Three special spellings:
NaN(unsigned),Infinity,-Infinity— the same three spellingsparse_floataccepts, soparse_float(to_string(x))closes for everyx(§11).
to_int(x)truncates toward zero and saturates (as JVMD2L):to_int(2.7) == 2,to_int(-2.7) == -2;NaNgives0, and anything outside theIntrange (±Infincluded) gives the nearer end (to_int(1.0 / 0.0)is the maximum value ofInt). This is written down because C's cast is undefined for all three of those inputs and on x86-64 answers the minimumIntfor all three — which is what native answered before 2026-07-30.==/!=default to structural equality and are available on any type (function types excepted — comparing functions is a compile error); a type can override that default withimpl Eq(§3.5).The ordering comparisons
</<=/>/>=have two mechanisms, not one:- On the scalars with
Ord(Int/Bool/String/Bytes/Unit, §3.5) and onFloatthey are native operations that resolve no witness — no impl lookup, no dictionary built. The two are one table (theOrdscalar table) plus its single exceptionFloat, which has a native<and no total order. OnFloatthe semantics are IEEE (any comparison involving NaN is false, see the numeric edge semantics above). Other concrete types bridge tocmpof the built-in traitOrd(see §3.5) — having an impl (hand-written orderive Ord) is enough to compare. [T: Ord]is a trait bound and goes through dictionaries: on a type parameter constrained by it,<resolves theOrdwitness just ascmp/sortdo.Floathas no impl (the reason for refusing is under "Floathas noOrd" above and is not repeated here), socmp(1.5, 2.5)does not compile while1.5 < 2.5stays legal.- The two mechanisms give the same answer on every scalar with
Ord(onString,<andcmpare the same comparison and the same order — code point order, see §3.5), and differ on purpose onFloat: native<is the IEEE partial order,Ordwants a total order, and Float only has the former.
- On the scalars with
- Arithmetic has two mechanisms too: on concrete
Int/Float,+ - * / %and unary-are native operations that resolve no witness, with no impl lookup and no dictionary; on any other type they resolve the matching trait's witness, soa + bisAdd'sadd(a, b)and its effect is the row that impl binds.[T: Add]is an ordinary bound, andInt/Floatsatisfy it. The left operand is checked with no expectation and the right one with the left one's type as its expectation, never the other way (lit(2.0) * tcannot inferlit's type parameter; writet * lit(2.0)). The exception: when exactly one side is a numeric literal, the other side is checked first and the literal is typed at its type (§1.5;0.5 * smeanss * 0.5, and withr: Floatbothr * 2and2 * rhold). This holds for arithmetic, comparison and==/!=; bitwise operators and shifts takeIntalone and are not covered. When both sides are literals, the whole operation's expected type reaches them only when it is a type that takes literals throughFromInt/FromFloat(let b: U8 = 200 + 100). AFloatexpectation does not pass through an operator: inlet f: Float = 1 / 2the1 / 2is still theInt0and a type error; write1.0 / 2.0(the same text does not mean 0 or 0.5 by its annotation; a library type's literals have no other kind to fall back to, so the expectation reaches them). Otherwise an integer literal next to a float literal takesFloat(3.14159 * 2 * rand1 + 2.0both hold), never the reverse, and two of one kind keep their defaults (1 / 2is still theInt0). The literal is recognised by its syntax before anything is checked, in one pass; this is not back-filling. A missing impl is reported before the two sides are compared:true + 1says thatBoolhas no+. The order of checking does not change the order of evaluation, which is still left to right. Printing user types: add
derive Showafter atypedeclaration to getto_stringand string interpolation support (Ordis also derivable, see §3.5; use commas for several:derive Show, Ord).Showis a built-in trait andderive Showmints exactly one impl, so you may also hand-writeimpl Show[T] { fn show(x: T) -> String }for a custom rendering, and use a conditional impl for a generic type (impl[T: Show] Show[Box[T]]). The signature ofto_stringis[T: Show]. The rendering is shaped like legal Dawn source:- a constructor with no payload →
Red; a constructor with positional fields →Circle(1.5); - a record →
Point { x: 0.0, y: 2.5 }(with field names); - a
Stringfield is double-quoted and escaped ("a\nb");Int/Float/Boolfollow their ownto_string; - containers render recursively:
List→[a, b], a tuple →(a, b),Option/Resultfollow the payload (Some(Red)).Map/Sethave no literal syntax and render as the call that rebuilds them:map.from([(k, v), ...]),set.from([e0, e1])— the public spelling, because the point of "reads like source" is that you can paste it back (it once printed the retired flat namemap_from, which pasted back reports undefined function; audit RD-14). - every field type must be printable (a function field, or a nested user type without
derive Show→ an error at the declaration); a generic type is printable if and only if its type arguments are (Box[Int]is,Box[fn(...)→...]is not) — on a generic typederive Showmintsimpl[T: Show] Show[Box[T]], and that is its bound. - A top-level
Stringgets no quotes, a nested one does.to_string("a")isa, while["a"]is["a"]— the quotes are the mark for "here is a value, not punctuation from what surrounds it". ACharis the same with a different delimiter:to_string('a')isawhile['a']is['a'](§1.5), soList[Char],List[String]andList[Int]all render differently. The trait methodshowis the nested one, so rendering a string through a[T: Show]bound does carry quotes;to_string/${}drop them only when the static type isStringitself. The rule is directed by the static type and not by the value:to_string("hi")ishi, while the same call written insidefn f[T: Show](x: T) = "${x}"gives"hi", because the static type there isTand the rendering goes through theShowwitness. This has nothing to do withDisplaybelow; it has held since the day the two layers were drawn apart. A type can take over the top-level layer itself, by writing
impl Display(§3.5).to_string(x)/${x}first ask whether the static type ofxhas aDisplayimpl: if it does, that is the rendering; only otherwise do the rest of this section's rules apply (including theStringidentity above). Two boundaries:- Only the type itself is asked; nothing is peeled. An opaque type inherits no rendering
from its target (§2.7), so an opaque type that reaches
to_stringalways has aShowof its own; with noDisplayonopaque type A = B,Arenders through its ownShowand does not borrowB'sDisplay. (Before 2026-09-24 rendering was inherited and the rule here was "peel one layer at a time and ask again at each"; its premise went, and so did it.) - The
Showlayer does not move. A type that writes aDisplaystill renders throughShowwhen it is nested inside a structure, and a type variable under a[T: Show]bound still renders through its witness (see above).
Showis the nested one andDisplaythe top-level one, matching Rust'sDebugandDisplay; there used to be a single trait doing both jobs, and these two rules are where the seam was cut.- Only the type itself is asked; nothing is peeled. An opaque type inherits no rendering
from its target (§2.7), so an opaque type that reaches
- a constructor with no payload →
Dot calls (UFCS)#
x.f(a, b) is another spelling of f(x, a, b): the receiver x fills the first parameter
of f, and the arguments in the parentheses fill the rest in order. This is pure sugar and
introduces no new node. It is the same call as the pipe x |> f(a, b) (§4.4); the three
spellings (f(x, a, b), x.f(a, b), x |> f(a, b)) have the same types, effects, evaluation
order and diagnostics. The dot is a postfix (row 13 of the §4.3 table) and binds tighter than
unary operators and |>: a + b |> f is f(a + b), while (a + b).f() applies f to the sum.
What recv is decides which kind of call recv.name(args) is, tried in this order:
- Module alias:
recvis the alias of a whole-module import, so this is the qualified callalias.name(args)(§10.3). An alias sharing its name with any binding of this module is a compile error, so this step never competes with the ones below. - Java static:
recvis a class name brought in byuse java(Math.floor(x),StringBuilder.new()). - Java instance:
recvis a value whose type is a Java class with an instance method namedname. - fn-typed record field:
recvis a record value with a fn-typed field namedname, so the function value stored in the field is called (§2.4). If a function namednameis also in scope, this spelling is a compile error (ambiguity); disambiguate with(r.name)(a)or the direct callname(r, a). - UFCS: otherwise
nameis looked up as a bare name in the current scope as a function, andrecvbecomes its first argument. What it finds can be a function of this module, a selectively imported name (use std/str.{split}), a prelude builtin, or a trait method (trait methods enter the module function namespace).
Step 5 only sees names that are in scope unqualified. A whole-module use std/str binds
only the alias str and does not bring split into scope, so s.split(",") does not hold
and the compiler reports:
error: undefined function: split
= hint: `split` is not in scope unqualified, but an import exports it: write
`str.split(...)`, or add `use std/str.{split}` to keep the dot call
Either write the qualified call str.split(s, ",") (or the pipe s |> str.split(","), which
needs no import of the bare name), or import selectively and keep the dot.
Named arguments work as usual; the receiver already took the first slot by position, so
x.f(self: y) reports "parameter self is given twice" (the named-argument item of §4.3).
A tail block also lands on a dot call: xs.each { x => e } is each(xs, x => e).
4.4Pipelines#
x |> f(a, b) is equivalent to f(x, a, b) — it puts the left-hand side into the first
parameter. x |> f is equivalent to f(x). Standard library APIs are all designed around
"the main datum is the first parameter" so that they work with pipelines.
|> is argument insertion into an ordinary call, and nothing else. The right-hand side is
parsed as one expression by the postfix rules of §4.3; if its outermost form already is a call
(f(a), m.f(a), r.m(a), make()(a)), the left-hand side goes in front of that call's
written arguments; otherwise the whole right-hand side is applied to the left-hand side.
x |> f(a) # f(x, a)
x |> f # f(x)
x |> m.f(a) # m.f(x, a) a module alias is not a receiver
x |> r.m(a) # r.m(x, a) the receiver stays put, it is not re-inserted
x |> make()(a) # make()(x, a) not make()(a)(x)
x |> One(a) # One(x, a) a constructor is just another thing being called
The pipeline introduces no node of its own, and so:
- Evaluation order is the order of the call written out: the target first (a dynamic callee or
a receiver), then each argument in written order, with the left-hand side being nothing but the
first argument.
lhs |> make()(arg)runsmake(), thenlhs, thenarg. This is specified, not an accident of the implementation — a static callee has no evaluation effect, which is why a common pipeline still looks as if the left-hand side runs first. No hidden temporary is introduced to make the left-hand side run first visually. - Callability, arity, duplicate named arguments and the record restriction are all decided as
for any call.
x |> One(1, 2)reports an arity error,x |> k(withk: Int) reports "not a function",x |> Point { ... }reports "not callable", andx |> Point(1)reports that a record must be built with braces (§2.4). - A module function in callee position is a call by name (§10.3): a module alias is not a
value, so
x |> m.fmeans the same asx |> f, namelym.f(x). Defaults, named arguments, inference of type parameters and effects at the call site, and effect operations all work as usual, and the diagnostics read as they do for the bare name.(m.f)(x)andwith v <- m.fare the same. This differs from a record'sx |> r.f(§2.4): a module alias never shares a name with a binding, so there is nothing to disambiguate. Only when used as a value (let g = m.f,map(xs, m.f)) ism.fa function value, which, like a barefused as a value, carries no defaults and no parameter names. - A function-valued field of a record is called dynamically as usual:
x |> r.callbackisr.callback(x).
4.5Lambda#
A function value takes at most 8 explicit parameters. Function types, lambdas,
and named functions or constructors used as values are rejected during checking
when they exceed this limit. Direct named calls and direct record construction
are not subject to this function-value limit. Ordinary effect operations take at
most 8 parameters; ctl effect operations take at most 7 because their handlers
also receive a continuation. Operations are checked at their declarations.
These limits are the same on the JVM and native backends.
let double = (x: Int) => x * 2
let add = (a, b) => a + b # parameter types may be omitted when inferable
let now = () => 0 # zero parameters
xs |> map(x => x * x) # parentheses optional for a single parameter
- There is exactly one spelling:
params => expr.paramsisx(the parentheses may be dropped for a single unannotated parameter),(a, b)or(), and each parameter may carry: Type.=>owns the single job of "an anonymous function starts here". - The
fnprefix has fully retired:fn(x) => eno longer parses as a lambda, and the former tail-onlyf(a) fn(x) => eretired in #206 phase 2 as well.fnnow appears only in named function declarations and function types; both old expression spellings have dedicated migration diagnostics. - If the body needs several statements, use a block:
(x) => { ... }. - Written after a call (on the same line) it is that call's last argument — a tail
block, see §4.3:
f(a) { x => e }(the brace head binds the parameters); this is the only trailing-argument spelling. A bare arrow cannot take the tail position: inf(a) (x) => ethe(x)is already the curried callf(a)(x), and the two readings cannot be told apart before the=>is reached. The diagnostic names this. - A form starting with
(is only decided after looking at whether a=>follows the):(a: Int)is a legal parameter list but not a legal expression, so a cover grammar (parse it as an expression first, reinterpret it on seeing=>) does not work here. When the decision fails it reports "invalid parameter list before=>" and names the offending token ((a + 1) => e). - A lambda cannot be used directly as the condition of an
if/while:if x => { ... }would eat the braces that were meant to be the loop body as the lambda's body. There is a dedicated diagnostic for this, not "expected{". - A closure captures bindings by value (capturing a
varis a compile error — to share mutable state, pass it explicitly). - There are exactly two exceptions, and both are cells: a handler's cells (§6.5) and the
staged
for's cells (§4.7). Each crosses only the closures it is licensed for, as a cell, and reads and writes go through the cell; every other closure is unchanged. - Division of labour between the arrows (design verdict, 2026-07-31, deliberately not
unified):
->is the clause arrow and appears only in "declaration-shaped" positions — function types (§2.2) and match arms (§5.1);=>is the expression arrow, whose only job is to mark where an anonymous function starts. Neither stands in for the other: when an arm body is a lambda (Some(f) -> x => f(x)), the two arrows are precisely the signal by which the reader tells the levels apart — the same reasoning by which Rust keeps->and=>separate, and the opposite of Scala's unification.
4.6if#
let sign = if x > 0 { 1 } else if x < 0 { -1 } else { 0 }
- The condition must be
Bool, and a branch body must be a block. - Used as a value,
elsemust be present and the branches must have the same type; used as a statement (the value is discarded)elsemay be omitted, and then the branches must beUnit.
4.7Loops#
for x in [1, 2, 3] { println("${x}") }
for (key, value) in entries { println("${key}=${value}") }
while queue.non_empty() { ... }
for/whileare statements of typeUnit, andvarmay be used inside the body to accumulate.for pattern in sourceandfor pattern in from..toreuse the complete recursive pattern grammar, including tuples, lists, constructors, qualified constructors, or-patterns, and a line-leading|continuation. The pattern must be irrefutable for the item type; a refutable pattern is a compile error, not a filter that silently skips non-matching items.- Pattern bindings are visible only in the loop body. The source and both range bounds are checked and evaluated in the outer scope, where those bindings are unavailable; code after the loop likewise cannot see pattern bindings or body locals.
- If an iterable source does not return (its type is
Never), it is still evaluated exactly once and remains the statement's bottom path; no iteration witness, loop, pattern binding, or body is reached. breakleaves the innermost loop andcontinuejumps to its next round. Both are expressions of typeNever(likereturn, they may appear in expression positions such as a match arm); they are legal only inside a loop body, and cannot cross a lambda/local-function boundary to reach a loop outside (a lambda is a function of its own; usereturnto leave it). There is no labelled form — to break out of several levels, extract a function and usereturn. comptime loops support them equally. The statements after awithare also the body of a closure (§4.10), so abreakthere likewise cannot reach the loop outside — the diagnostic nameswithinstead of talking about a lambda the author never wrote.for pattern in eiterates any type that implementsIter(a built-in trait, §3.5):eis evaluated exactly once before the loop starts; iteration desugars into ordinary trait calls toiter_start/iter_done/iter_next/iter_get, and the element type is that impl'sItem. The five std containers (List/String/Bytes/Map/Set) iterate out of the box; a parameter bound by[C: Iter]in a generic function is equallyfor-able (dictionary forwarding). The iteration order is the impl's cursor order (Stringby code point,Map/Setthe same asentries/to_list).Staged
for: when the type ofeimplementsStagedIter,for pattern in e { body }is not a host loop. It is handed to that impl'sstaged_for.bodybecomes a closure, and the impl decides how to run it (tileir uses it to record a device loop).eis still evaluated exactly once, in the outer scope, before any cell is opened. Each outervarthe body mentions, by reading or writing it, is resolved by scope and excludesvars the body declares itself. Before the loop, each suchvargets a cell (StagedVar.var_open), opened in declaration order; inside the body its reads and writes go through the cell, and after the loop it is read back. A nested staged loop reuses a cell the outer one has already opened. The type of such avarmust implementStagedVar, or it is a compile error (a host value means nothing in a recorded body). An ordinary lambda in the body cannot capture a carried name.break/continue/return/?cannot leave a staged body. The body may perform only the effectsStagedIter[R]declares. A rangea..bis always a host loop, and a type that implements bothIterandStagedItercannot be iterated byfor.var acc = zeros(o) # Tile[Float]; tileir provides StagedVar[Tile[D]] for k in d_range(0, K / T) { # tileir provides StagedIter[DRange] acc = mmaf(load_at(a, [k]), load_at(b, [k]), acc) } store_cell(o, acc)for pattern in a..bsupports half-open integer ranges (not viaIter, withIntitems).ais evaluated beforeb; each bound is evaluated exactly once, and both are evaluated before the loop starts. These guarantees also hold when the range is empty.
Idiomatic style prefers map/filter/fold; loops are there for performance-sensitive
spots and for taste.
4.8Indexing#
let x = xs[i] # List[T] -> T; out of range panics (negatives included)
let v = m["key"] # Map[K, V] -> V; a missing key panics (the message carries the key)
let c = rows[1][0] # chainable, composes with ?/./()
[]is an assertion, the get family is an enquiry:xs[i]is for the case "the index is necessarily valid, out of range is a bug" (panic semantics, as in Rust); when out of range / a missing key is a normal branch, useget(xs, i)/map.get(m, k)(which returnOption).- Indexing is resolved by the built-in trait
Index(§3.5): the impls forList(Idx = Int) andMap(Idx= the key type) ship with the language, and a user type gets[]by writing oneimpl Index; a type with no impl is a compile error. Anopaque typedoes not inherit its target'sIndex: a subscript hands elements out of the module, the same shape as rendering, so[]needs the type's ownimpl Index(§2.7). - comptime supports
Listindexing (out of range is a compile error). - Read-only — there is no
xs[i] = v, andIndexhas no corresponding write method. Lists and maps are immutable; a user type, even a mutable one, is not written through[]. - The comparison operators
</==route to the native implementation for scalars without going through a trait (see §4.3) —Indexonly governs[]. So does arithmetic, see §4.3.
An impl for a user type:
type Grid = { w: Int, cells: List[Int] }
impl Index[Grid] {
type Idx = (Int, Int)
type Item = Int
fn index(g: Grid, p: (Int, Int)) -> Int = {
let (x, y) = p
g.cells[y * g.w + x]
}
}
let g = Grid { w: 3, cells: [1, 2, 3, 4, 5, 6] }
let v = g[(2, 1)] # 6
fn first[C: Index](c: C, i: C.Idx) -> C.Item = c[i] # a generic consumer
The three out-of-range criteria. The language promises three out-of-range policies only,
and every operation taking an index/range parameter (the [] of this section and the
library functions of §11) belongs to exactly one of them, classified by what the parameter
means rather than settled function by function:
- Assertion — the parameter is a position, the caller claims it is valid, and out of
range is a bug:
xs[i],m[k],bytes.at,str.at,pvec.index/nth, an invalid range forcursor.slice→ panic (negative indices included). - Enquiry — out of range / a missing key is a normal branch the caller wants to tell
apart: the
getfamily (list.get,map.get, absence inset.has, a miss in theindex_offamily (str/bytes/list), a non-match instr.strip_prefix/strip_suffix, malformed input tobytes.from_hex/from_base64and tofspath.extension(package)) →Option(orBool). - Clamping — the parameter is a range, saying "whatever part of this stretch is
there", and does not assert that the endpoints exist:
list.take/drop/slice,bytes.slice,str.slice/take/drop,cursor.next/prev/back/seek→ each end is clamped into[0, len](a negative becomes0, an over-long onelen),from > togives the empty sequence, and it never panics.
The one named exception: cursor.char(s, c) returns the sentinel -1 at the end rather
than an Option (§11, "std/cursor"). It is the primitive that advances character by
character — wrapping every step in an Option is one allocation per step; the same sentinel
is wrapped by std into a panic over at bytes.at (criterion 1), while here at
cursor.char it is part of the public contract (the lexer of packages/json depends on
it). Apart from this, no library function may express out of range with a sentinel value.
4.9return#
fn classify(n: Int) -> String = {
if n < 0 { return "negative" } # guard clause
if n == 0 { return "zero" }
"positive"
}
return expr/ a barereturn(in aUnitfunction only) returns early from the innermost function — inside a lambda it leaves that lambda (the same scoping rule as?).returnis an expression of typeNeverand may appear in any expression position (a match arm, for instance).- The enclosing function (or the lambda's expected type) must already declare a return type — it is unavailable inside an inferred function that omits its return type.
- Unavailable inside the sugared region: the statements after a
withare the body of a closure (§4.10),returncannot get out there, and the compiler reports an error on the spot, namingwith.
4.10The with statement#
fn write_all(path: String, bytes: Bytes) -> Unit !io = {
with f <- bracket(FileOutputStream.new(path), s => s.close())
f.write(bytes)!
f.flush()!
}
with x <- f(a, b) is parser-level sugar: all the statements after it in the block
are packed into x => { ... } and attached as f's last argument, and the whole call
becomes the block's value at that point. The snippet above is equivalent to
bracket(FileOutputStream.new(path), s => s.close(), f => {
f.write(bytes)!
f.flush()!
})
- Inside a block only, and only as a statement: it has to eat "the rest of the block",
and with no block there is nothing to eat (
fn f() = with x <- g()is a syntax error, and the hint says to add braces). - Several
withs in one block nest naturally — the second eats the part after itself. Withbracket, the release order is therefore inside out by construction (§9.8.2). - The landing point is the same one as the tail block's (§4.3):
f(a, b)grows one argument,r.m(a)grows one argument, and a bare namegbecomesg(x => ...). Sowithcombined with a tail block is just an ordinary call; there is no third rule. - A
withcallee opens no tail block (#206):with x <- f(a) { ... }is refused —withis about to attach the rest of the block asf's last argument, so the{would be a second closure on the same call. The diagnostic names this; parenthesise the call to attach the block, or drop thewith. ?passes through transparently: what the closure hands back is the value of thewithcall, and that is the block's value, so anErrpropagated byexpr?inside the sugared region becomes the return value of the enclosing function — andbracket's release still runs (cross-check inscripts/spike-native/with_sugar.dawn).return/break/continueare rejected inside the sugared region, and the diagnostic nameswith: what they would have to cross is a closure boundary the author never wrote, and "getting out" only means what the reader thinks it means when that block happens to be in tail position. A loop of its own inside the sugared region is unaffected (aforafter thewith, withbreakbelonging to it).The sugared region cannot touch a
vardeclared before thewith, and the diagnostic names the closurewithintroduced: the sugared region is that closure's body, and a closure captures by value and refuses to capturevar(§4.5). So everyvardeclared before thewithcan be neither assigned nor read in the rest of the block after it; avardeclared after thewithbelongs to the sugared region itself and is unaffected. To carry a value from before the sugared region into it, bind a snapshot withletfirst, or pass it in as a parameter. The stagedfor(§4.7) is another closure the author did not write, and the only outervars it can reach are the ones that can be carried in a cell.var n = 1 let base = n # snapshot it first; reading n after this is refused with x <- g var acc = base + x # a var declared inside the sugared region is fine- Write one name at the binding site, with the same rules as a lambda parameter; there is no
_form (nor do lambda parameters have one).
Why
bracketand notdefer: the protected interval is always exactly one closure call,return/breakcannot get out of it at the language level, and so the compiler owes no escape-rewriting pass. The criteria are indocs/core-move2-design.md§2.6 and §6.
4.11List literals and their element forms#
list_lit = "[" [ list_elem { list_sep list_elem } [ list_sep ] ] "]"
list_sep = "," | NL (* a newline is the same separator as a comma;
adjacent, the two count as one *)
list_elem = expr (* an ordinary element: contributes one *)
| ".." expr (* a spread: contributes 0..n *)
| if_no_else (* a conditional element: contributes 0 or 1 *)
if_no_else = "if" expr block { "else" "if" expr block } (* no final else *)
column([
header,
..body, # body: List[Widget[Msg]], spliced in whole
if m.note != "" { text(m.note) }, # a line only when there is something to say
help,
])
Separators: a newline is a comma. Inside a multi-line list literal the commas between elements may be dropped, and one newline is one separator:
column([
header
..list.map(m.todos, t => item_row(t))
if m.note != "" { text(m.note) }
dim(text("commands"))
])
The two separators may be mixed within one literal, a trailing comma or newline is still allowed,
and both [] and a [\n] spanning lines are still zero elements. This is a pure syntactic
addition: [1, 2, 3], a multi-line literal with commas, and a trailing comma all parse to
exactly what they parsed to before. dawn fmt does not rewrite either spelling into the other
either; the author's choice of separator is kept, since for lists the formatter only adjusts spacing within
a line and indentation, never the line breaks the author wrote.
What is dropped is only the comma at a line break. Two elements on one line still need one:
[a b] is a syntax error, and the diagnostic is the same "expected ], found b" at the same
position.
Which newline is a separator is decided by §1.7, not by this rule. An element is parsed by the
ordinary expression rules first, an operator that may lead a continuation line (|>, ., a binary
operator) is still swallowed by the element above it, and the newline left standing is the
separator:
[
xs
|> f # one element: `|>` leads a continuation line, which has nothing to do with lists
a
-1 # two elements: `+`/`-` never lead a continuation (§1.7), so `-1` is a prefix
# minus opening a new element
]
+ has no prefix meaning, so a + at the start of a line lands at an element position and is
refused there, with "expected an expression, found +". That is what the +/- exception does at
an element position, not an extra rule of the list literal's own.
A line-leading .. always reads as a spread, never as a range continuing the line above. ..
is not an expression operator (all four of its meanings are positions, see below) and the range
a..b appears only in a for header (§4.7), so the left-hand literal below is two elements rather
than one range:
[
a
..xs # a spread element
]
[0..3] # still refused: `..` between two values, and not at the start of an element, is
# neither of its meanings
The three element forms hold only inside a list literal. Tuples, records and constructor
arguments do not know them; the pattern [x, ..rest] (§5.1) is a different thing — the rest of
a destructuring, spelled symmetrically with the spread here and meaning the opposite.
The spread ..xs: the operand must be a List[T], and each of its elements is laid out in
place. Anything else is ".. spreads a list, but this is …". .. is never legal in expression
position; it appears in exactly these places: the range in a for header (§4.7), the record
spread P { ..p } (§2.4), the rest pattern (§5.1), and the element spread here.
The conditional element if c { x }: a true condition contributes one element x, a false
one contributes none. x's type joins the element type, and it need not be Unit — the
rule that an if without an else must be Unit (§4.6) does not apply at this position,
because nothing here needs it to produce a value.
An else if chain is one element form as a whole: if a { x } else if b { y } contributes
0 or 1, taking the arm whose condition is first true, and none if no condition is. However deep
the chain, it is one element form.
A chain that does end in an else is not an element form; it is an ordinary element, and it
means exactly what it meant before this feature existed: [a, if c { x } else { y }, b] is
always three elements. Hence a deliberate asymmetry — the else-less form may be omitted, the
form with an else may not be expanded:
[a, if c { x }] # 0 or 1: a conditional element
[a, if c { x } else { y }] # always 1: an ordinary element, one of two
[a, if c { ..xs } else { ..ys }] # illegal: `..` is not at an element position
The reason is to take the smallest cut. Letting the form with an else expand too means
making both branches of an if be a run of elements rather than a value — Dart's
collection-if/collection-else, a second if grammar that holds only inside a collection
literal, and with it a string of further questions about whether the else branch may nest a
for, another if, and so on. The else-less form needs none of that: in ordinary
expression position it could only ever be Unit, so moving it to an element position collides
with no existing meaning. The same reasoning rules out a collection-for ([for x in xs { f(x) }]):
list.map already writes it, and adding for immediately raises "why is there no while".
Evaluation order is left to right, and the element forms do not change that. A conditional element's body is evaluated only when its arm is taken.
Typing: every element form's contribution joins one element type T, and the literal is a
List[T]. An ordinary element contributes its own type, a spread contributes the T of its
List[T], a conditional element contributes the common type of its arms' bodies.
The expected type is pushed down, which is what the element forms are really for: once T
is settled — from the literal's expected type, or from any element already checked — every later
element is checked at T. A spread's operand receives List[T], and each body of a
conditional element receives T. So:
# text: fn text[M](s: String) -> Widget[M], where M appears only in the return type
[text(s)] # error: cannot infer type parameter(s) M
[header, text(s)] # fine: header settles M as Msg first
[header, if c { text(s) }] # equally fine: the expectation crosses into the body
The third line is why intermediate bindings like let note: List[Widget[Msg]] = if …
disappear: a standalone [text(s)] binding has no sibling to ask and must be annotated,
while the same things written as elements of one literal need no annotation. Elements that
cannot be checked without an expectation (a bare None, a nested []) are still checked in a
second round, so they do not depend on source order.
Breaking change in meaning (after v0.67.0):
[if c { <a Unit expression> }]used to be legal, of typeList[Unit], and always of length 1 — theifinside was an ordinary element, evaluated as a statement and producing(). The same source now has length 0 or 1, and the body is not evaluated at all when the condition is false. Nothing is reported; the behaviour changes silently.The affected programs are exactly the family "a conditional element whose body has type
Unit", no more and no less: before the element forms, anifwithout anelsein expression position could only beUnit(§4.6). Landing this, every.dawnfile in dawn-lang (566) and in the dawnop-site backend (84) was scanned file by file with the new parser: zero existing occurrences.No transitional diagnostic was added, because it could only be an error: Dawn's diagnostics have no severity —
Diagcarriesmsg/lo/hi/hintand everything is an error. And making "a conditional element whose body isUnit" an error would carve a permanent hole in the new form's typing rule to protect a class of programs measured not to exist; aList[Unit]of length 0 or 1 is coherent under the new rule. A transition ends; a special case in a typing rule does not.
5.Pattern matching#
match shape {
Circle(r) if r > 100.0 -> "big circle"
Circle(r) -> "circle ${r}"
Rect(w, h) -> "rect ${w}x${h}"
Point -> "point"
}
Adjacent match arms must be separated by a physical newline or ,. A comma may be
followed by a newline, and a trailing comma after the final arm is allowed. Whitespace alone is
not a separator: match x { 0 -> 1 1 -> 2 } reports the missing newline or comma at the second
1, rather than treating a token that looks like a pattern as an implicit boundary. This does
not change §1.7 newline continuation: a newline nested inside (), [], or {} still belongs
to the arm body expression.
5.1Pattern forms#
| Pattern | Example | Matches |
|---|---|---|
| Literal | 0, "yes", true | matches if equal |
| Binding | x | always matches, and binds |
| Wildcard | _ | always matches, binds nothing |
| Constructor | Some(x), Rect(w, h), Rect(w: w, ..) | destructures by position or by name, .. ignores the remaining fields; parentheses only |
| Record | Point { x, .. } | field destructuring; braces only |
| Tuple | (a, b) | |
| List | [], [x, ..rest] | empty list / head and rest |
| Or | 0 | 1 | 2 | any alternative matches (the bindings of every alternative must agree) |
| Guard | pat if cond | the pattern matches and the guard is true |
| has the lowest precedence in a pattern. It may occur recursively inside constructor, record,
tuple, and list patterns, and is collected in source order as a flat n-ary or-pattern. (pat) is
grouping only; a tuple pattern still requires a comma. The | may start a continuation line, as in
A\n| B. A newline followed by a pattern without | still starts the next match arm.
A pattern's first | is optional, the same rule as a sum-type declaration's (§2.3): | A | B -> 1
is A | B -> 1, so a multi-line or-pattern can give every alternative its own line and its own bar,
and dawn fmt lines those bars up with the arm:
fn light(c: Color) -> Int =
match c {
| Red
| Amber -> 0
Green -> 1
}
A line-leading | belongs to patterns alone: it does not continue a bitwise or from the line above
(0 -> x followed by a line | A -> 2 is two arms, not x | A); a bitwise or that wraps keeps its
| at the end of the line, the way a line-leading - is a prefix and never continues a subtraction
(since v0.78.0).
At run time the first matching alternative is selected, with no backtracking inside that or-pattern. A match-arm guard applies to the whole or-pattern and runs at most once. If it is false, matching continues at the next arm. The body also runs at most once.
Every alternative must bind exactly the same name set, and each shared name must have the same type.
The enclosing let or var selects mutability once for the whole pattern, so alternatives cannot
differ on it. The first alternative supplies the canonical binding in the shared environment; later
alternatives only provide another path that assigns its value.
5.2Exhaustiveness#
match must be exhaustive. The compiler checks exhaustiveness on ADT/Bool/Option/Result/tuple;
a missing arm is an error and the missing constructors are listed. A match on
Int/String/Float must have a _ or a binding arm as the catch-all.
let also accepts irrefutable patterns: let (a, b) = pair, let Point { x, y } = p, and
let m.Only(x) = value for an imported single-constructor type. Or-patterns use the same usefulness
check, so let true | false = flag is valid while let true = flag remains refutable and is
rejected.
The compiler normalizes type-known complete alternatives, including true | false, before the
usefulness search. The remaining search has a deterministic work budget. If that budget is exhausted,
an otherwise legal match or structural let is rejected with pattern analysis exceeded its
complexity budget instead of guessing about exhaustiveness. The diagnostic advises simplifying
nested alternatives or splitting the pattern into smaller matches.
6.Effect system#
6.1Model#
An effect row has two axes.
The base axis's two ground points are pure (the default, written nowhere) and io.
!io covers every observable side effect: files, network, clock, random numbers, printing,
mutable global state, and all Java interop. Two kinds of non-ground atom live on the base
axis as well, beside io rather than as special cases of it: effect variables (§6.3) and
associated-effect projections (§6.5).
The label axis is the finite set of named effects the user declares with effect (§6.5).
It is independent of the base axis: !io does not cover named effects. So an !io function
that performs !Ask still has to write !Ask in its signature.
Clocks. Reading a clock is !io, and there are two primitives: the wall clock
io_clock_wall_ns (nanoseconds since 1970-01-01T00:00:00Z; the host may set it, so a later
reading can be smaller and a reading can be negative) and the monotonic clock
io_clock_mono_ns (an arbitrary origin, and only the difference of two readings means
anything; both backends read the same clock, System.nanoTime on the JVM and
CLOCK_MONOTONIC on native and wasi). Both are nanoseconds in one Int, representable to
about 2262; the resolution is the host's and is not promised. Both are for std only (§10.6)
and refused by compile-time evaluation (§7.2): a reading would make one source compile to
different bytes at different times. A program reads the clocks through std/io's named
effect Clock (§6.5), whose production handler is io.with_clock_real: io.now_wall_ns()
answers the wall clock as an Int, io.now() a monotonic reading as an io.Instant
(opaque, §2.7), and io.elapsed_ns(a, b) is b - a, or 0 when that is negative.
io wears two faces and they are read separately, which is this section's main clause: under
containment io is an upper bound, so a signature that promises !io stands over a purer
implementation (including one that only performs effect variables); under union io is not
an absorbing element, so !io !e may not cancel !e and both atoms stay in the normal form
(printed !(e|io)). The full rules are in §6.6.
Both axes are trivially decidable: union is componentwise union of finite sets plus one boolean or, containment is set containment per axis.
Ambient effects#
io is the only ambient effect. Ambient effects are a tier, not "a lowercase atom": one is
written into any row without a declaration, occupies no evidence slot, is answered by no handler
and subtracted from the row by none, and any signature that writes it admits it. A program cannot
declare an ambient effect (effect declares named effects on the label axis), so today the tier
has exactly one member, io.
What an atom is comes from tables, not from its spelling, looked up in this order: the ambient
table → the named effects in scope (including selective imports and renames, §10.2) → a module
alias's exports (!dep.Ask) → a type parameter's associated effects (!T.E) → this signature's
effect variables. For a name no table knows, the spelling only decides which diagnostic is
reported: an uppercase name was meant as an effect and gets "unknown effect" (or "is a type, not an
effect" when it is a type); a lowercase name is an effect variable, introduced by appearing (§6.3).
The two always agree on a legal program because the case is enforced at the declaration site:
effect takes only an uppercase name, an effect parameter only a lowercase one, and a rename may
not change the case class.
pure is likewise reserved only in effect position: it is not a keyword, is an ordinary identifier
outside effect position, and is refused everywhere inside one (§6.3).
6.2Rules#
- The effect of a function body = the union of the effects of every call in it.
- A function whose signature is not marked
!io, with an io effect appearing in its body → compile error (the error points out which call introduced io, and suggests adding!ioto the signature or eliminating that call). - Marked
!iobut the body is pure → allowed (room reserved for evolution). Dawn has one diagnostic level and no lints; a "redundant!io" rule will either be an error some day or not exist. - A pure function is guaranteed: same arguments return the same value, no observable side
effects. The compiler may fold it, deduplicate it, and call it at comptime on that basis.
The one exception is
dbg(§8.3): the stderr line it writes is defined as a diagnostic side channel and not a side effect of the program, which is why its type is pure. Named effects are inside that guarantee too: a function value's type carries its full effect row, and the only thing that can subtract a label from a row is thewith handlethat really answered it (§6.5), so a function whose signature says pure cannot run somebody else's handler arm. - A function value's effects are answered at the call, not where the value was written. A closure's row is what its body performs, unrewritten at the creation point; if the row an individual call instantiates carries a named effect nobody answers, the error is reported on that call (§6.5).
panic/todo/assertdo not count as io — they do not return (divergence is not an effect).- No absorption. An effect row may not drop a label, an effect variable or an
associated-effect projection unless a handler answered it. Union drops nothing (§6.6); and when
a function value goes into a slot, the slot's row must carry its row atom for atom, because a
dropped atom is a dropped piece of evidence. The one subtraction point is
with handle(§6.5), and it subtracts only the label it answers. A signature promising!ioover a variable it binds is not a drop: that is containment (§6.6), and the variable still has an evidence slot from that signature's binder list. - A local
fn's row follows the same rule as a lambda's and a written function type's:fn g() -> Int !Ask = ask()is legal, and so are!eand!T.Ewhen the enclosing signature binds them; the same body written as a lambda and as a localfncan do the same things. The one difference is that a lambda's row is inferred from its body while a localfn's is a written, fixed promise (as a top-level signature's is, §3.1), so using a label, variable or projection the row leaves out is an error; a localfncannot introduce a new effect variable (it cannot declare type parameters either). Before v0.78.0 a localfn's row could only be!ioor pure (SPC-19).
6.3Effect polymorphism#
Higher-order functions use effect variables to forward the effects of their arguments:
fn map[T, U](xs: List[T], f: fn(T) -> U !e) -> List[U] !e
fn compose[A, B, C](f: fn(A) -> B !e1, g: fn(B) -> C !e2) -> fn(A) -> C !(e1 | e2)
- An effect variable
!eneeds no declaration; appearing in a signature introduces it, and its scope is the whole signature. It may also be given an explicit binder in the parameter list:fn map[T, U, !e](xs: List[T], f: fn(T) -> U !e) -> List[U] !e. The two spellings are equivalent. When a signature carries an explicit binder, that name resolves only to it within the signature; a name with no binder is still introduced where it appears, and the two may be mixed in one signature. A binder carries no bound ([!e: X]is an error), its name must be lowercase (!Eis a named effect, not a variable), neither!ionorpurecan be a binder name, and the same binder cannot be written twice.pureis refused in every row position, not only as a binder: it reads as "pure only" and would behave as "any row at all", which is the opposite. The empty row has its own spelling,!()(§6.6). The binder list is also where the order comes from: it binds in the order written, and names with no binder are still introduced in the order they first appear. - A type declaration can bind effect parameters too:
alias Mapper[T, U, !e] = fn(T) -> U !e,type Box[!e] = { f: fn(Int) -> Int !e }. Only through a binder: a type declaration is not a signature, so "introduced by appearing" does not apply there, and writing a variable the declaration does not bind is a compile error — put it in the parameter list, write!io, or leave it pure. Several!ein one declaration are one variable; the!eof another declaration is unrelated. A named effect is not under that restriction:alias A = fn() -> Int !Askandtype Boxed = { f: fn() -> Int !Ask }are both legal. A label is not a variable and has no binder to speak of; it means "whoever calls this function value supplies the handler", which is exactly where evidence travels today (§6.5). - An effect parameter a nominal type binds carries empty evidence only. What is restricted is
instantiation, not spelling: the
!eoftype Box[!e],type Chain[!e]oropaque type Hidden[!e]may only solve to a row that owes no evidence, which means pure,!io, or the variable itself (forwarded unchanged,fn store(b: Box) -> Box = b). A row carrying a named label, an associated-effect projection, or a variable bound elsewhere may not travel into a consumer through this channel. The reason is that the channel does not exist: a variable a signature binds has a hidden evidence parameter to travel in (§6.5 "Implementation"), one a nominal type binds has none, a record is not a frame, and a nominal type's identity keeps only its name and its type arguments (two bullets down), with nowhere to remember a row per use site. To make a row that really owes evidence travel, bind the variable on the function that runs the closure (fn f(g: fn() -> Int !e) -> Int !e), or use a transparentaliasand write the effect argument at the use site (next bullet). - An effect argument may be written at the use site, and only a transparent
aliastakes one:Thunk[!io],Thunk[!Ask],Thunk[!e],Mapper[Int, String, !(Ask | e)]. The position takes everything the inline row position takes, and nothing less: a transparent alias is its expansion,Thunk[!Ask]andfn() -> Int !Askare two spellings of one type, and a row that can be written inline does not become unwritable by passing through an alias. The argument is resolved in the use site's own scope, read exactly as the same row written inline:!enames the variable the consumer's signature binds (introduced by appearing),!Askis the named effect visible here,!T.Eis the projection here. Substitution runs position by position in declaration order, the alias's own variable is replaced, and the result joins whatever row the expansion already had under set semantics (flattened, deduplicated, andioabsorbs nothing, two bullets down): onalias IoThunk[!e] = fn() -> Int !io !e,IoThunk[!Ask]isfn() -> Int !(io | Ask). The argument shares the brackets with the type arguments and is positioned within its own list (the declaring side splits[T, U, !e]the same way), so writing one between two type arguments does not move the type arguments. A record, a variant and anopaque typedo not take one: such a type is its name plus its type arguments, with nowhere to remember the row, so accepting one would leave two types either indistinguishable or distinguishable but printed under one name. A transparent alias has no identity to remember it in; it expands in place, and the argument lands in the expansion. - Omitting the effect argument stays legal and is not an arity error:
Mapper[Int, String]gives the type arguments only. The omitted slot is still the declaration's own effect variable, solved from context: store a pure closure and it solves to pure there, store an!ioclosure and it solves to!iothere. A consumer's own row has to cover it, and an!ebound by that signature alone does not cover a variable bound elsewhere. An alias's way out is to write the argument (Mapper[Int, String, !e], with!ebound by this signature); a nominal type has no such way out, and a consumer writes!io(!iocovers any row). !(e1 | e2)is a union, stored normalised: union is componentwise (a boolean or on the io bit, plain set union on the variables, the projections and the labels),pureis the identity (and can be dropped), and a row that can take a smaller shape takes it (!(e|e)is!e).ioabsorbs nothing:!(io | Ask)is exactly!(io | Ask)and!io !eis exactly!(e|io); neither collapses to!io. The full rules, and their closure, are in §6.6.- An effect variable spans both axes:
!ecan be instantiated to a row carrying labels. Inmap(xs, x => ask())the!eoflist.mapinstantiates to "pure + {Ask}", so performing a named effect inside aforbody goes through just as well. - Instantiation puts no limit on how many atoms a variable stands for: one
!emay solve to a single label, to a union of labels (!e := !(Ask | Tell)), to another variable, or to a mixture of labels and variables (!e := !Ask !e2). There is no "one variable, at most one label" rule. - Instantiation at the call site: for
compose(inc, tag), ifincis pure andtagis!io, the effect union of the result type normalises to!io; if both are pure it normalises to pure and the result can be called in a pure context. - Instantiation at the call site: in
map(xs, println),e = io, so the whole call is io.
6.4(removed) unsafe_pure#
This section used to define unsafe_pure { <expression> }: an expression stamp, legal only inside
std, that masked !io to pure, together with the compile-time Java folding it licensed (route C)
and the --comptime-ffi switch. It was removed on 2026-09-24 along with the keyword (ruling 5): it
had zero use sites in the whole ecosystem, and what it carried — "one host call supports a pure
function" — is carried on the declaration side by the intrinsic contract (§11): a primitive's
effect row ships with the compiler and is guarded by the bootstrap and the differentials, rather
than vouched for one call site at a time. unsafe_pure is an ordinary identifier from now on. The
design and the reasons for removal are in `docs/pure-ffi-design.md` and
`docs/effects-window-design.md` §6. The section number is kept so that
references to §6.5 and §6.6 across the tree do not shift.
6.5Named effects and with handle#
Qualified names and renaming#
An effect is an ordinary module member, reached by the same routes as a type or a function: with
use library as dep, !dep.Ask names the public effect Ask declared by library; with
use std/io, !io.Fs works the same way (io there is a module alias, not the ambient effect
io, which is an undotted atom). The lowercase first segment is a module alias, not an effect
variable; !T.E retains its uppercase-first associated-projection meaning. Qualification is
available in every effect row, including function types, effect arguments and unions such as
!(io | dep.Ask | e). It also names a single ground effect in trait defaults and impl bindings
(effect E = !dep.Ask), and a handler selector (with handle dep.Ask { ask() => 42 }). Handler
arms use the operation's unqualified name within the selected effect.
A selective import can rename an effect: after use std/io.{Fs as Files} a row writes !Files and
a handler with handle Files { ... } (the general renaming rule is in §10.2). A rename changes this
module's name table only; the effect's identity, the operation names it brings along and the name
diagnostics print all stay the same.
Qualified, selectively imported and renamed names resolve to the same provider-owned effect identity. Different modules' same-named effects remain distinct; a whole-module import does not introduce the effect or its operations into the unqualified namespace. When two libraries export effects of the same name, using both means qualifying or renaming them; importing both bare is a clash error. Missing aliases, missing or private effects, and exported members of the wrong kind are errors, never implicit effect-variable binders. Only two segments are supported. Ground bindings still reject variables, associated projections and unions; qualification does not relax that rule.
effect declares a set of operation signatures; the call site calls an operation directly, and the
with handle lexically nearest to the call answers it.
There are two tiers of arm. A tail-resumptive arm is an ordinary closure that is "called in
place, its return value is the operation's result", with no continuation capture. A control arm
carries one more clause (op(args…) resume k => expression): k binds the continuation of that
operation, and the arm's value is the value of the whole with handle block. A control arm can be
written only under an effect declared ctl, and the rules for it are collected in "Control arms"
below. Whether the binding clause is there is the arm's kind marker. For the design and the verdicts
see `docs/effects-design.md` and
`docs/oneshot-design.md`.
Declaration#
effect Ask {
## Ask the context for an Int.
fn ask() -> Int
}
effect State {
fn get() -> Int
fn put(v: Int) -> Unit
}
ctl effect Fetch {
fn fetch(url: String) -> String
}
- Effect names are UpperCamelCase and share one namespace with types and traits.
- A declaration may carry a
ctlprefix, which says "an operation of this effect may be suspended by a control arm".ctlis a contextual keyword (ashandleandresumeare), never reserved, and it is still usable as an identifier, a parameter name and a function name. With a visibility the order ispub ctl effect E:ctlsits whereopaquesits, between the visibility and the keyword it modifies. - An operation is an ordinary function signature: no body, and it must not carry an effect annotation of its own (an operation's effect is the effect it belongs to); it currently must not carry type parameters either.
- Operation names enter the module's function namespace: sharing a name with any top-level
declaration of this module (an ordinary function / a trait method / another effect's operation)
is a redefinition error; sharing a name with something introduced from elsewhere (including a
same-named operation brought in by another effect) is an error too. The operations of a
pub effectcome in together withuse m.{Ask}, andm.ask(…)can also be written. - An effect itself carries no type parameters;
effect Yield[T]is currently unsupported.
Spelling and propagation#
fn sum_three() -> Int !Ask = ask() + ask() + ask()
fn logged(x: Int) -> Int !Ask !io = {
io.println("asking")
ask() + x
}
- A named effect is written in the effect position, alongside
!ioand effect variables; several annotations can be stacked (!Ask !io), or written as a union (!(io | Ask)). - Deciding what
!nameis, is a table lookup; the order and the diagnostics for a miss are in §6.1 "Ambient effects": a hit on aneffectdeclaration in scope → named effect; when no table knows the name, a lowercase one is an effect variable and an uppercase one reports "unknown effect" ("is a type, not an effect" when it is a type). - Performed but nobody answers (neither declared in the signature nor handled) → the error is
reported on the call nobody answers, with two ways out: add the annotation to the
signature, or
with handleon the spot. Calling an operation directly, that call is the operation call; where the label arrives through a function value or an effect-polymorphic call, the report lands on that call, not on the callee's definition. - Only the entry module's
pub fn mainmust have an empty label set — it has no caller to supply evidence, so the "nobody answers" error lands onmain's own signature. An ordinarypub fnmay carry the label of apub effect, for its caller to propagate or handle; it is a private effect in a public surface that the export-surface validation refuses (§3.3).
with handle#
fn demo() -> Unit !io = {
with handle Ask { ask() => 42 }
io.println("${sum_three()}") # 126
}
with handle E { arms… } is one clause form of the with statement (§4.10): the rest of the
block lives in that handler's scope, exactly isomorphic to with x <- f(…), and it inherits all
of its discipline — legal only inside a block, the rest is a real closure, return/break/
continue are refused (the diagnostic names with handle), ? passes through transparently.
- An arm has the form
op(args…) => expression(a tail-resumptive arm) orop(args…) resume k => expression(a control arm, see "Control arms" below),=>being the same notation as in a lambda (an arm is a closure to begin with), and arms are separated by newlines. Every declared operation gets exactly one arm: too few arms, too many arms, and an arm whose name does not belong to the effect are all compile errors. Both kinds of arm may sit in one handler. - One
handle, one effect; handling the same effect again is inner shadowing outer, which is legal. - Handling an effect the block never actually performs is allowed (harmless dead evidence).
Handler-local state (cells): the head of the arm table may declare any number of
vars, each one slot of mutable state private to this installation. A cell is reachable from the arms of this installation and from the rest of the block after thewith handle: an arm reads and writes it, the rest of the block reads it. That is the one and only surface by which a stateful handler hands what it accumulated back to the installation point; there is no second route such as areturnarm. Three attendant rules: cells must all come before the arms (interleaving them with arms is a compile error with a diagnostic of its own, so that no reader has to guess whether declaration order means anything); the type annotation is mandatory (there is no context to infer from inside the arm table's braces); and what is declared here is a position, not a new spelling (varand assignment are the same two constructs as in §4.1, and a cell merely adds one more legal place to declare one). A cell itself has no spellable type; a user cannot write one down.with handle Emit { var acc: List[Int] = [] # the cell: annotated, and ahead of every arm emit(n) => { acc = acc ++ [n] } } body() acc # the rest of the block reads it: this is the hand-back surfaceA
vardeclared before the installation point is out of reach after it (this is §4.10's capture-by-value discipline showing up here): everyvardeclared before the installation point can be neither assigned nor read in the rest of the block after thewith handle; avardeclared after the installation point is entirely normal. The diagnostic names the closurewithintroduced and offers two ways out: bind a snapshot withletfirst, or pass it in as a parameter. An arm is a closure too, and likewise refuses to capture an ordinary outervaror to assign to one. The one exception is this installation's own cells: thevars declared by the item above are readable and writable from the arms, because they are not outer bindings captured into the arm but this installation's own state.var n = 1 let base = n # snapshot it first; reading n after this is refused with handle Ask { ask() => 42 } var acc = base + ask() # a var declared after the installation point is normal- Typing rule: let the rest closure's effect be
(base, L)and each arm body's effect be(base_i, L_i); then the block that installs the handler is recorded as(base ∪ ⋃base_i, (L ∖ {E}) ∪ ⋃L_i). The subtraction is at this node: the rest is a closure and this node is what supplies its evidence, so this node is the only thing that can takeEoff the row. Subtraction follows supply, not execution: under a control arm the rest may be run by a continuation that has travelled elsewhere (see "Control arms"), and the evidence it wants is still the pack this node built at the installation point and handed over. A handler that never suppliedEcan therefore take nothing away. The base axis is not subtracted:!iois not somethinghandleanswers. The effects the arm bodies perform themselves (including io, including other labels) are all unioned back onto the block — an arm runs where the handler is installed, not where the operation is performed. "A row losing a member" happens atwith handleand nowhere else, and it does not enter unification. ?leaves early and the state is discarded:?still passes through awith handletransparently (§4.10), cells or no cells. What an early exit discards is the state of this activation, whose cells are destroyed with it; the line in the rest of the block that reads a cell is one that was never going to run anyway, so there is no hand-back surface that looks certain to run and quietly does not. A continuation a control arm stored before the early exit is unaffected: it carries the cells of its own activation, and resuming it resumes another lineage of state. This reading is not an invention of this language: it is the literature's local state interpretation (Wu / Schrijvers / Hinze, Effect Handlers in Scope, Haskell 2014), the standard answer for a state handler installed inside the error boundary.
use std/io
effect Emit {
fn emit(n: Int) -> Unit
}
fn body() -> Unit !Emit = {
emit(1)
emit(2)
}
pub fn main() -> Unit !io = {
with handle Emit {
var acc: List[Int] = []
emit(n) => { acc = acc ++ [n] }
}
body()
io.println("${acc}")
}
[1, 2]
That is the shape cells exist for: the emits scattered through body() accumulate into acc in
the order they were performed, while body's signature says only that it performs !Emit and says
nothing about where the values end up.
Lexical scope and the supply point#
Evidence (the handler's arms) is resolved lexically: an operation call binds to the lexically
nearest with handle. Evidence enters the callee with the call; a closure captures no handler at
its creation point. Consequences:
- The handler is the call site's, not the creation point's. A closure built inside a handle
block that escapes outside the block and is then called does not carry the original handler:
its row still holds
!E, and the handler in scope at the call answers it, or the error is reported there. The type states "who has to supply the handler", and has nothing to do with what the arms do; a handler with pure arms yields an escaping closure that is!Etoo. - A cell does not escape its lexical region. A state cell is reachable only from the arms and
the block remainder of this installation, and being captured by any closure that does not
belong to this installation is a compile error, with a diagnostic for each of the three
phrasings: a lambda hand-written inside an arm (what is licensed is the arm, not a closure the
arm builds), the block remainder after a
with x <- f(…)(withintroduces another closure, and that closure is outside thewith handlethat declares the cell, so the way out is to read the cell before thewith), and the arm of another installation. Nor can a cell itself be passed out of the region as a value: it has no spellable type. The contrast is an ordinarylet: a value bound withletinside a handler's region may still be captured by an escaping closure, and this ban tightened cells and nothing else. A continuation is the one thing that can carry this activation's state out of the block, and what it carries out is not a new name: the block's rest closure is still applied in place exactly once, what escapes is that closure part-way through, and the continuation carries the cells of its own activation. So "no second name reaches this cell" holds at the level of names, unchanged. - Performing this same effect inside an arm body binds to the outer handler for that effect (it does not answer itself); with no outer one the arm owes the label and the error is reported as usual.
- Reentrancy: an arm gets the evidence environment of the layer before the installation
point, so a cell of one installation is touched only by the arms of that installation. Nested
installations each have their own cells, and what the inner one accumulates does not leak into
the outer one.
The classic lost-update shape "read the cell, call out, write the cell" cannot be built at the
tail-resumptive tier: whatever route the intervening call takes, it never gets back to the
same cell. Under a control arm it can be built: the arm reads the cell, calls
k, the rest performs the same effect again, and the same arm is entered again. The answer is that a cell's unit is one activation, and the re-entered arm reads the cell of this activation; when one installation is activated recursively, each activation has its own, and resuming a continuation goes back to the one belonging to its own activation. (This used to be promised for the tail-resumptive tier only, with an escape hatch saying that once multipleresumes were allowed a cell would have to be copied along with the continuation. This section is where that hatch is used.) - Performing another effect inside an arm body is answered by the handler in scope at the installation point, not by the one in scope where the operation was performed: the arm runs at the installation point.
use std/io
effect Ask {
fn ask() -> Int
}
fn escaping() -> fn() -> Int !Ask = {
with handle Ask { ask() => 7 }
() => ask() + 1
}
pub fn main() -> Unit !io = {
let f = escaping()
with handle Ask { ask() => 2 }
io.println("${f()}")
}
3
ask() => 7 answers the operations performed inside its own region, and f() happens outside it,
so f's row keeps !Ask and the handler here in main answers it.
Control arms: the resume binding and one-shot continuations#
A control arm has the form op(args…) resume k => expression, with k named by the author. It can
be written only under a ctl effect; an arm of a non-ctl effect that carries the binding clause is
a compile error, and the diagnostic names that arm and asks for the declaration to be changed.
Suspendability is a property of the declaration, because a row names effects and that is all the
Java boundary can read. ctl is a licence rather than a requirement: a tail-resumptive arm with no
binding clause is legal under a ctl effect.
- The answer type: let the type of the rest of the
with handleblock beA; then the block's type isA, every control arm's body must beAtoo, andk's type isfn(R) -> A(its row is the next point), whereRis the return type declared for that operation.Ais not a new type variable, it is the type of the rest of the block; in a handler with no control arm this rule says nothing. - The arm's value is the block's value, and has nothing to do with the operation's declared return type. Not resuming at all is legal: the arm's value becomes the block's answer directly, and the part of the block after the suspension does not run again.
kis an ordinary function value, with no type of its own: it goes in an ADT field, in a cell, in a list, and it may be called after the frame that installed the handler has returned. Its row is the io bit of the rest of the block's row, on the base axis: if the rest has io, or an effect variable or an associated-effect projection (either may be instantiated with io, so they fold in conservatively),kisfn(R) -> A !io; otherwise it is the purefn(R) -> A. Labels never travel. Dropping the labels is not leniency but a consequence of the supply-point discipline (see "Implementation" below): the pack the rest of the block wants was built at the installation point and handed to the continuation, so whoever calls it neither has to supply one nor can. The io bit travels because it occupies no evidence slot and asks the caller for nothing; the one thing it forbids is resuming or discarding a rest that does io from a frame whose signature says pure. So §9.8.1's "a fault only comes from io" needs no exception for continuations.kis deep: calling it reinstalls this installation, so the rest of the block performing the same effect again after the resumption is answered by this installation, reading the cells of this activation.- One-shot is dynamic: a continuation is spent at most once. The type does not say so and does not try to; spending it a second time is a panic (the message is in the table below), not a catchable fault.
?inside a control arm: it is judged againstA, and so requiresAto be anOption/Result. It means abandon the continuation and letNone/Errbe the answer of the whole block. Before the call tokit abandons a continuation that was never resumed; after the call tokit throws away the value the rest of the block already handed back, which is a value and not state.- It does not cross to Java: a function value whose row names a
ctleffect cannot be converted to a Java functional interface, and the conversion point is a compile error (§9.4), because there is no Dawn frame under a Java frame to suspend into. Only written labels are judged statically. On the other two axes, an effect variable and an associated-effect projection, the conversion point cannot know what the caller will instantiate!ewith; those are caught at run time, and a suspension nothing can catch is a panic rather than a wrong answer.
use std/io
ctl effect Ask {
fn ask(n: Int) -> Int
}
fn release(tag: String) -> Unit !io = io.println("release ${tag}")
fn resumed() -> Int = {
with handle Ask { ask(n) resume k => k(n + 1) }
ask(1) + ask(10)
}
fn bare() -> String !io = {
with handle Ask { ask(n) resume k => "dropped at ${to_string(n)}" }
with r <- bracket("bare", release)
"held ${r}, then ${to_string(ask(1))}"
}
fn explicit() -> String !io = {
with handle Ask {
ask(n) resume k => {
discard(k)
"discarded at ${to_string(n)}"
}
}
with a <- bracket("outer", release)
with b <- bracket("inner", release)
"held ${a} and ${b}, then ${to_string(ask(1))}"
}
pub fn main() -> Unit !io = {
io.println(to_string(resumed()))
io.println(bare())
io.println(explicit())
}
13
dropped at 1
release inner
release outer
discarded at 1
resumed is one round trip: k(n + 1) runs the rest of the block with that value as the result of
that ask, and what the rest produces is the arm's value, which is the block's. bare and
explicit are the two halves of the ruling below.
Abandoning a continuation#
A continuation that was captured and never resumed ends in one of two ways, and the language recognises only an explicit trigger.
- A bare drop runs nothing. Drop the last reference and not one
releaseof the brackets inside the frozen computation runs (bareabove: there is norelease bareline, and that is the whole assertion). This is a ruling rather than an oversight. Triggering cleanup off reclamation would give one source program two observable behaviours on two backends, because the JVM's GC does not fire when native's reference counting does; and the destructor route is closed too, since areleaseis arbitrary user code with a lexical environment that no runtime can call on its own. The memory a dropped continuation holds is reclaimed by the implementation, and this specification does not promise when. discardis the sanctioned path.fn discard[T, U, !e](k: fn(T) -> U !e) -> Unit !eIt resumes the abandoned computation with a poison, so the unwind lands on every
bracketbetween the suspension and the installation and runs itsrelease, innermost first, and then answersUnit. The parameter's type is the continuation's own type and nothing narrower:kwas ruled an ordinary function value above, and this does not take that back. So any function value satisfies the type and whether it is a continuation is decided at run time.!eis the continuation's own row: discarding a pure rest's continuation is pure, discarding one that carries io is!io, and discarding owes the same row resuming does.discardspends the same ticket a resumption spends: a discarded continuation cannot be resumed, and a resumed one cannot be discarded. Comptime refuses it.
Each of the four misuses has a verbatim message, and they are written once, in the output of the
program below, in this order: resuming a continuation already spent; discarding one already spent
(discard-then-resume, resume-then-discard and discard-twice are one ticket, so they are one
message); discard on a function value that is not a continuation; suspending during a discard's
unwind. (Writing them a second time would be an uncorroborated copy: this document's gate runs the
program and compares the output fence, and it does not read a table of prose.)
use std/io
ctl effect Ask {
fn ask(n: Int) -> Int
}
type Held =
| Ready(v: Int)
| Waiting(k: fn(Int) -> Held)
fn plain(n: Int) -> Int = n
fn twice() -> Int = {
with handle Ask { ask(n) resume k => k(n) + k(n) }
ask(1)
}
fn escaping() -> Held = {
with handle Ask { ask(n) resume k => Waiting(k) }
Ready(ask(1))
}
fn spent() -> String !io =
match escaping() {
Ready(v) -> "resumed ${to_string(v)}"
Waiting(k) -> {
discard(k)
match catch_panic(() => discard(k)) {
Ok(_) -> "discarded twice"
Err(e) -> e.message
}
}
}
fn suspending() -> String !io = {
with handle Ask {
ask(n) resume k => {
discard(k)
"unreachable"
}
}
with r <- bracket("r", tag => {
let _ = ask(99)
()
})
"unreachable ${r} ${to_string(ask(1))}"
}
pub fn main() -> Unit !io = {
match catch_panic(() => twice()) {
Ok(v) -> io.println(to_string(v))
Err(e) -> io.println(e.message)
}
io.println(spent())
match catch_panic(() => discard(plain)) {
Ok(_) -> io.println("discarded a plain function")
Err(e) -> io.println(e.message)
}
match catch_panic(() => suspending()) {
Ok(s) -> io.println(s)
Err(e) -> io.println(e.message)
}
}
dawn: continuation resumed twice
dawn: continuation discarded after it was already used
dawn: `discard` expects a continuation
dawn: a `ctl` operation was raised while a continuation was being discarded
Four more rules:
- The poison is not a failure: neither
catch_panicnorcatch_faultsees it. This is necessary rather than convenient: catching it would mean the abandoned computation can be resurrected out of its own cleanup and carry on from the barrier to the end.bracketreleases as it always does, because it guards without catching (§9.8.2). - An inner activation inside the frozen span is discarded with it: an inner handler activation
that a bubble passed over is inside the abandoned span too, and the
releases under it run as well, innermost first. - The unwind may not suspend: a
releasemay do io, may fail, and may open abracketof its own, but it may not perform actloperation. The frames below the walk are already committed to going away and nothing can resume into them, so an unwind that suspends is an unwind that may never finish. - A failure escaping a
releaseduring the unwind does not follow §9.8.2's replacement rule: the remainingreleases still run, and the first failure is the one delivered, handed to whoever calleddiscardafter the walk is finished. The reasoning is recorded in §9.8.2.
The two backends promise observational agreement and nothing more. The mechanism of freezing and
resuming is deliberately left out of this specification: the JVM freezes the stack, native stops
running that stack and switches to another, and that difference is intentional. What is promised is
that one source program produces the same observable behaviour on both: the answers, the number and
order of the releases, and the message and the position of a panic. The corpora that compare those
byte for byte are scripts/spike-native/ctl_resume.dawn, ctl_nested.dawn and ctl_discard.dawn.
Boundaries (v1)#
These are the v1 boundaries. "A written named effect" is no longer the reason for any of them; each has its own criterion.
- trait / impl methods: a method's row is a full row — effect variables, named labels and
associated-effect projections are all admitted.
The variable is introduced by this very signature (§6.3's implicit introduction counts as
bound here), synthesises no evidence, and leaves the dictionary slot untouched; a label is
one exactly-typed evidence parameter on the slot; a projection is one erased slot — which
evidence record fills it is the impl's decision, and the boundary only knows the slot exists.
In
trait Container[C] { fn wrap(c: C, body: fn() -> Int !e) -> Int !e }the!eis the caller's: one call ofwraptakes a pure closure, another takes an!ioone. The impl's and the trait's effect variables are paired by position (the order each signature introduces them: the row, then the parameters left to right, then the return type); the spellings need not agree, and unequal counts are an error. Once paired and reduced through this impl's associated-effect bindings, the impl's row must be covered by the trait's: pure always is (doing less than the row admits to is not a lie); an unrelated label or!iois not; written labels are the exception and must match exactly on both sides — each label is one hidden evidence parameter, the method's shape and not just its promise, and dropping one would change the method's arity. A trait method's default body is checked like any other body: it can forward along the variable or the erased evidence slot (call the closure), but cannot install a handler — a rigid effect has no name forwith handleto spell. - comptime / const initialisers: compile-time evaluation performs no named effect and cannot install a handler either.
- Local functions: the rule is §6.2, rule 8. A local
fnis a closure, its value always carries one evidence pack, and the call builds that pack from the row it wrote, so every label, variable and projection in the row is supplied by the call; one the row leaves out cannot be reached from the body (reaching an enclosing binding lexically is not holding that evidence at run time), which is why the row has to be written in full. - Written function types:
fn(…) -> T !Eis legal in everyTypeRefposition, including parameters, return types,letannotations,aliastargets, record/variant fields, generic arguments, tuple elements, and trait / impl method parameter types. A written label reads as "whoever calls me supplies the handler", which is exactly its runtime meaning. An effect variable (fn() -> Int !e) is another spelling rather than a migration: it forwards the row to its own caller and takes a closure of any row.!ioand associated-effect projections (fn() -> Int !C.E) are equally legal. A record field can hold a labelled closure, and the field's row is the row to supply when it is taken out and called. - Function values: a labelled named function can be passed as a value; the row enters the
value's type and the call site supplies the evidence. An effect operation itself cannot: an
operation call is "read the evidence field + call the closure", so there is no function symbol to
take, and the diagnostic says so. Write it as a lambda (
() => ask()). - Effect-polymorphic code forwards, it does not install handlers:
with handle Enames one concrete effect syntactically, so the point where a handler is installed is always monomorphic. A!ein a signature can only pass the row along; installing a handler means writing the effect's name. The other face of the same wall is thatpub fn main's labels must be empty.
Implementation (informative)#
Each effect E makes lowering synthesise an ordinary record type (whose name the user cannot
spell), with one field per operation holding its closure, plus one trailing env field: the
evidence environment the arms run in, filled at the installation point. with handle
constructs that record and binds it to a local; an operation call = read the field + call the
closure, and the arm's evidence slot is taken from env rather than from the performing site.
There are two evidence conventions, and exactly one translation point.
A named call gives one slot per atom of the row. Every label written out in a signature appends one exactly-typed hidden evidence parameter to the function, placed after the dictionary parameters, in ascending effect id order; every signature-introduced effect variable appends one erased parameter; every associated-effect projection then appends exactly one erased parameter, after the labels' evidence, ordered by (subject, trait, member name), which the impl side's bridge restores to the concrete evidence record.
A function value always has exactly one slot. Whatever its row says, a function value's runtime
arity is "the parameters written, plus one evidence slot", and a pure function value carries an
empty pack there. A pack is an immutable chain of (key, evidence, outer) nodes addressed by
atom key: labels, effect variables and associated projections take their keys from separate
bands, so two rows meeting in one slot is a single cons and a query is a walk by key, with no
reordering and no chance of a shifted slot. The rule for building a pack at a call site: every
ground atom of the row (a written label, a projection already reduced to a label) conses one
node on top of the non-ground environment the frame already holds; when the row has no ground atom
and exactly one non-ground atom left, the pack is that slot, and nothing is allocated. A
superset is harmless: lookup is by key, so a node nobody asks for costs one step. Only a missing
key is an error.
The two conventions change hands in the wrapper lift_fn_value synthesises: each slot the named
side wants is read out of the function value's single pack by key. Higher-order library functions
(list.map and its ilk) take the "one non-ground atom left" path, forwarding as-is and building
nothing.
The boundary clause: a value leaving the language carries a creation-point snapshot. Both conventions above put the supply point at the call, because the call is the only place a handler frame is known to be live. Once a function value has been converted to a Java functional interface (§9.4) there is no such place: the adapter is entered from a Java frame with no Dawn frame under it, and neither the moment nor the thread of the call is chosen by the language. So here, and only here, the value carries its own evidence: the conversion point builds the pack that is in scope at that point by the same rule as any other supply point, stores it in a field of the adapter object, and every entry Java makes hands it to the closure. The row is charged at the conversion like an ordinary call site, and an atom the scope cannot answer is still an error, so the snapshot is not a way of losing a label but a way of answering it at the last place an answer exists. Nothing about the rules inside the language changes: evidence is supplied at call sites, a snapshot is taken where a value crosses out, and the two meet only at that one step.
This is why union has no absorption (§6.6). Both conventions above read their shape off the effect row: the named side reads whether slots exist and how many, the function-value side reads which keys the pack should hold. So any row equation that deletes a label, a variable or a projection makes the static row and the runtime carrier disagree: a signature opens one slot fewer, or a pack is built one key short, while the reading side still looks the key up and finds nothing. That is the "effect evidence missing" compiler-invariant panic, on a program that checks clean. §6.6's absorption ban is the static half of the same fact.
One alternative is refused here explicitly: let the runtime fall back to a dynamic lookup by label whenever a slot is missing, so that absorption would cost performance and not correctness. Not adopted. It trades the invariant "an atom the row states has a place in the carrier" for a fallback path, and a fallback path runs only on the programs that happen to be short a slot, so the part nobody tests is exactly the part that is unsound; lookup would also become two semantics instead of one walk by key. The invariant stands.
6.6Row equivalence and normal form#
An effect row is built from four kinds of atom: the base axis's io, effect variables (§6.3),
associated-effect projections (§6.5), and the label axis's named effects. pure is the row with no
atoms, written !(); it goes anywhere !io goes (a signature's row, a function type's row, an
effect argument, an impl's associated-effect binding), and means what leaving the annotation off
means. pure is not a spelling for that row: it is not a keyword, so written into a row it becomes
an ordinary effect variable, and an effect variable is solved by any row at all. !pure is
therefore an error (§6.3).
Row equivalence is generated by and only by the five rules below.
- Union is componentwise: the union of two rows is a boolean or on the io bit and plain set
union on each of the three sets (effect variables, associated-effect projections, named labels).
Hence
|is commutative, associative and idempotent, andpureis the identity. Stacked annotations equal one union:!Ask !iois!(io | Ask). - The normal form is the smallest shape: on the base axis, all components empty is
pure, io alone is!io, no io and a single base atom left is that atom itself, and anything else is the union of those components; an empty label set carries no label layer, and a non-empty one is that base row plus those labels. - The normal form has a settled order: effect variables in introduction order (first
appearance within the signature, with explicit binders taking the front in the order written),
associated-effect projections by (subject type parameter, trait, member name), named labels by
effect id. That order is also the evidence slots' layout order (§6.5 "Implementation").
Rendering is a separate matter: printed atoms are sorted by name, so
!io !eprints as!(e|io). - Projections reduce through the impl: once the subject is settled, an associated-effect projection is replaced by the row that impl binds, and the result is normalised again by the rules above. A ground row contains no projection.
- Two rows are equivalent exactly when their normal forms are equal.
An equation not written here does not hold. In particular: union has no absorption law.
!(io | Ask) and !(e | io) are normal forms that do not move, and any reading that equates them
with !io is not a consequence of these five rules; it is a sixth rule being added.
Union does not distinguish effects that occupy an evidence slot from environment effects. io
is an environment effect and occupies no slot of its own (§6.5 "Implementation"). That is not a
reason for it to absorb !e: !e can be instantiated to a named effect that does occupy a slot,
whether a slot exists is known only after instantiation, and union is computed before it. This is
the clause most easily argued away in reverse ("io takes no slot, so what does absorbing cost?"),
so it is written as a clause rather than a comment: what would be absorbed is an atom, not a slot.
Containment is a separate relation and takes no part in equivalence. !io covers pure, io
and effect variables (including unions of them); named labels and associated-effect projections are
judged by exact set containment, where !io covers neither !Ask nor !C.E. The division of
labour is sharp:
- A signature is judged by containment. A body purer than its signature is allowed (§6.2 rule
3), and a signature may promise
!iowhile standing over a variable it binds, with the evidence still arriving, because the slots come from the binder list and not from the row. - A function value is not. It has no binder list, and its row is the whole of what a call site reads to build the pack, so on the way into a slot the slot's row must carry its row atom for atom. Widening upward stays free: a pure closure goes anywhere and the extra keys go unread. What is refused is a row with an atom the slot does not have.
Row subtraction is the one exception to "atom for atom". When a function value goes into a
parameter of the form !(e | R), where e is the only effect variable the callee's signature
binds and R is the set of concrete atoms in that row (named labels, io, associated-effect
projections), then e is bound to S \ R, where S is the argument's row, and the argument is
accepted. A parameter written !(e | io) therefore takes a closure that both does io
and raises a named effect; before this rule the only spelling that took one was the bare !e.
The step carries a co-occurrence precondition: every row in the callee's signature that
mentions e must also write down every atom of R. Every solution of !(e | R) ~ !S agrees on
the rows that contain R, so which one was chosen can be observed only from a row that mentions
e without R. Where the signature has no such row, e := S \ R is the only observable answer
and choosing it costs no principality. Where it has one, any choice would be a choice made on the
caller's behalf that the caller never wrote, so the argument is still refused, and the diagnostic
names the occurrence that makes the choice observable.
Where one call passes several function-value arguments through the same e, each argument's
residual S_i \ R is joined (the least upper bound on this lattice) into e's binding:
neither the first argument nor the last one wins. This is what a bare !e parameter has always
done, and two spellings of one parameter row do not answer differently.
The step carries a companion condition: e is looked for on each parameter's unsubstituted
declared row. Once an earlier argument has bound e, the substituted row has no variable left to
find.
The argument's row need not contain R. Where S is short of R, S \ R is S with atoms
taken off it that it never had, and e is bound to that and the argument accepted. This is safe
for the same reason widening upward is free: the call site builds the pack from the
instantiated row rather than from the argument's own row, and the pack is read by a key chain
rather than by index. A closure short of its slot is therefore handed a superset, and the extra
keys go unread.
The other shapes are unchanged. The other direction is still refused: an atom in the argument's row that the slot does not have is the atom-for-atom clause above, which this section does not touch. A row with two or more effect variables is still refused as well: the remainder has no principal split between them, and this specification does not invent one.
7.comptime#
7.1Form#
const CRC_TABLE: List[Int] = comptime { crc32_table() }
fn lookup(d: Int) -> Float =
SIN_TABLE.get(d % 360).expect("table covers 0..360")
comptime { ... } is an expression: it is evaluated at compile time by the compiler's
built-in interpreter, and the result is embedded in the output as a constant. The
right-hand side of a top-level const is implicitly in a comptime context.
7.2Constraints#
- comptime code must be pure (it may call any pure function, including those of this module and of dependency modules).
- The result type must be constant-serialisable — that is, the compiler can rebuild
the value at class initialisation time:
Int/Float/Bool/String/Unit, plusList/tuples/records/ADTs made only of those. Function values are not allowed. An opaque type is as serialisable as its target (§2.7), with one exception:std/cursor'sCursoris not. ItsIntis an offset into the backend's own representation of the string (UTF-16 code units on the JVM, UTF-8 bytes on native), and a constant is folded once and written into the Core both backends read, so a folded position holds for at most one of them. To compute a position at compile time, hold the string and walk to it where it is used. This exception is a corollary of §11's "the measure never becomes an observable value", not the whole of it: rendering is shut off too, orconst S: String = to_string(c)would go around this clause and fold the measure into the artefact as a string (and the interpreter counts code points, so that would be a third answer again). The exception comes off once cursors have a single currency.Map/Setare not allowed for now: they are HAMTs overArray, and the comptime interpreter has noArrayprimitive;Listworks because the interpreter carries its own list representation, not because it can runstd/pvec.Bytesis not allowed either, for the same kind of reason: the interpreter's value type has no byte-string case, so aBytesvalue has nowhere to live at compile time, and thestd/bytesprimitives are refused at comptime across the board. ThatByteshasEq/Hash/Showdoes not change this; whether a type can be a constant asks whether the interpreter can hold it, not which traits it supports. To prepare byte data at compile time, compute aList[Int]or aStringand turn it intoByteswhere it is used. - Evaluation has a step budget (10⁸ steps by default, tunable with
--comptime-fuel); exceeding it is an error — which guarantees compilation always terminates. A primitive whose output grows with its input is charged by the size of what it produces, before it allocates: a String++by the code points of its result, a List++by the elements of its right operand (the left one is shared, see §2.2), andrange, list slicing,join,code_points/from_code_points, case mapping, string slicing and quoting by the elements or code points they produce. The budget so bounds memory and the time of one step as well: doublings = s ++ spays the length of its result each time, not a few steps. A count too large for anInt(arangewhose ends are further apart than anIntcan say, for instance) is over any budget, not zero. - There is no Java interop and no io inside comptime (constraint 1 guarantees this automatically).
- Pure does not mean foldable: some pure builtins are refused at comptime too, because the
values they operate on are ones the interpreter cannot represent (the
Array,BytesandMap/Setmentioned above), or because the answer they would fold belongs to the build rather than to the program. Which ones is looked up per function, not enumerated here:dawn doc --builtinsgives every compiler builtin's entry a"comptime"field whose value is"ok"or"refused". The flag is taken from the list the interpreter actually refuses, and a gate holds it, in both directions, equal to the interpreter and to the# comptime: rejectedmarkers inselfhost/builtins.dawn. A std function written in Dawn carries no such field: whether it folds depends on which builtins its body reaches, and when a constant fails to evaluate the error names the refused one along the call chain (dawn checkshows it).
7.3Explicitly out of scope#
comptime cannot generate types, cannot generate declarations, and cannot introspect the AST. It is only "run a piece of pure Dawn code ahead of time". This specification provides no metaprogramming facility.
8.Error handling#
8.1Recoverable: Result / Option + ?#
fn parse_config(path: String) -> Result[Config, String] !io = {
let text = read_file(path)? # on Err, return that Err early
let json = json.parse(text)?
Config.from_json(json)
}
- The postfix
?applies toResult[T, E]:Ok(v)unwraps tov,Err(e)makes the current function returnErr(e)immediately. Likewise forOption[T](NonereturnsNoneearly). - The return type of the function containing the
?must be a compatibleResult/Option(theEtypes must agree; there is no automatic error-type conversion). ?is the expression-level propagation shorthand forOption/Result: it propagates the current expression'sNone/Errbranch as the current function's return.return,break, andcontinueare separate explicit jumps, not part of this shorthand.
Across error types: write a local helper, don't wait for the language to give you one.
? requires E to agree, so a function returning Result[_, HttpError] cannot ? a
Result[_, String] directly. The fix is a 4-line function at the boundary:
fn as_http[T](r: Result[T, String], status: Int) -> Result[T, HttpError] =
match r { Ok(v) -> Ok(v)
Err(m) -> Err(http_error(status, m)) }
After that let rows = as_http(repo_call(...), 500)? is all it takes, and ? handles the
rest.
A
map_errwas once added to std for this, and reverted on 2026-07-19: of the 102 cross-layermatches in dawnop-site, 94 came apart with "?plus the helper above" (another 8 already had the same error type and needed no helper at all), and the entire effect ofmap_errwas to shrink that helper from 4 lines to 1, once per project. What actually untangled those 102 sites was?and a local helper, not a new library function.
? inside a lambda returns from that lambda, so bridges inside closures can collapse the
same way.
8.2Unrecoverable: panic#
panic(msg, at: Loc = caller()): prints panic: <msg> at <at> to stderr; the process exits
non-zero. No stack is printed. An omitted at is the location of this call; a function that
reports a failure on its caller's behalf passes its own at on (§8.4).
todo(at: Loc = caller()) is equivalent to writing panic("not yet implemented") in the same place, and
passes any type check (its return type is the bottom type Never).
Location suffix: when panic, todo, expect, postfix ! or assert (§3.4) fails, the
message ends with the location of the call, at <path>:<line>:<col>, filled in by the
compiler at compile time from the call site:
<path>depends only on the source tree, not on the command line or the working directory: relative to the project root in a project (src/main.dawn), relative to the entry file's directory for a single file (p.dawn), and<package>/<path in package>for a[deps]package.<line>and<col>count from 1, and the column counts code points, the same ruler as a diagnostic's--> path:line:col. They point at the start of the failing expression: the namepanic/todo, theoofo.expect(..)and ofo!, theassertkeyword.- The location is part of the message; the
messagethatcatch_panichands back carries it too (§9.8). - std has no position path: a failure in std that omits
atcarries no location. Used as a function value,panic'satis an ordinary parameter (fn(String, Loc) -> Never), and the caller must supply aLoc. - There is no run-time stack trace: Core carries no spans, and a location exists only for a
call site known at compile time (reasons in
docs/source-location-design.md).
Postfix !: o! unwraps Option[T] into T, and panics on None. The semantics are
those of expect(o, msg); the only difference is that the message is generated by the
compiler — it contains the call that produced the None and the source location
(unwrapped None from URI.create() at src/http.dawn:23:13), so there is no need to invent a
placeholder string for it.
let uri = URI.create(url)! # instead of .expect("uri")
let base = HttpRequest.newBuilder()!.uri(uri)! # instead of .expect("b") / .expect("b-uri")
The reason ! exists is exactly §9.2: Java wraps every reference return in Option,
while the vast majority of JDK methods never return null in practice, so unwrapping is the
normal case.
- Applies to
Optiononly. ForResult, propagate with?(§8.1) or usematch. - Same precedence as
./[], left-associative:a()!.b()!is((a()!).b())!. - A line may end in
!— it is not a binary operator, so it does not trigger a line continuation (§1.7). Inx! != v,!=is still a single token (longest match wins when splitting), and the comparison is on the unwrapped value. - When there really is something to say, still use
expect(o, "reason")— that is what it is there for.
get/map.get return Option (enquiry); the subscript c[i] panics on an out-of-range
index or a missing key (assertion, §4.8; the semantics are fixed by that type's Index
impl, List/Map as above); Int division by zero (/ and %) panics — being a panic,
catch_fault does not intercept it (§4.3, numeric edge semantics).
8.3Debug output: dbg#
dbg[T: Show](x: T) -> T returns x unchanged and writes one line to stderr:
[<path>:<line>:<col>] <the argument's source text> = <show(x)>
let n = dbg(a * 2) # stderr: [src/main.dawn:4:11] a * 2 = 8
let m = xs |> len |> dbg # stderr: [src/main.dawn:5:11] xs |> len = 3
- Pure by type, the identity by semantics. The value of
dbg(x)isx; the line is a diagnostic side channel and not part of the program's semantics: it is not in the effect row (no!iois required, and it may be written in a pure function), no handler intercepts it, and its count and order are not guaranteed to survive future optimisations. This is the only place in the language where a type says pure while output is written (it is the exception to rule 4 of §6.2). <path>:<line>:<col>follows the rule and the ruler of §8.2's location suffix, and points at the start of the call (the namedbg; for a pipee |> dbg, the start ofe).- The argument text is the call site's source as written, not a re-printing. An argument that
spans lines is folded onto one: a run of white space that crosses a line break becomes one
space, or nothing just inside a bracket (
twice(\n a +\n 1)readstwice(a + 1)). - Rendering uses
Show(the nested rendering, so aStringis quoted); a type with noShowis a compile error, the same discipline as interpolation. - At comptime it is the identity: the constant folds as usual and nothing is printed at compile time (§7).
- It belongs to the program being debugged.
dbgin a module loaded through[deps], or in std, is a compile error; the same package checked as a project of its own may use it. There is no build mode that stripsdbg: its semantics are the identity, so stripping it would change nothing but the output. - It can only be called, not used as a function value (
let f = dbgis a compile error): a function value has no call site and no argument text. To pass it, wrap it in a lambda:(x) => dbg(x). - It takes one argument. To see several values, pass a tuple:
dbg((a, b)).
8.4Caller location: caller() and Loc#
A function that reports a failure on its caller's behalf takes the line that called it
through a parameter whose default is caller():
fn assert_eq[T: Eq + Show](a: T, b: T, at: Loc = caller()) -> Unit =
if a != b { panic("${show(a)} != ${show(b)}", at: at) }
fn sums() -> Unit = assert_eq(1 + 1, 3) # panic: 2 != 3 at src/t.dawn:4:21
Locis an opaque type the compiler provides (owned bystd/loc); its value is<path>:<line>:<col>, with the path, line and column of §8.2's location suffix."${l}"andshow(l)are that text;loc.path,loc.lineandloc.colread the three parts (split from the right, so a path holding a:still splits). Onlycaller()makes aLoc.caller()may only be the whole default value of a top-level function's parameter. When the argument is omitted, its value is the location of this call: the start of the call expression (the namef; for a method call or a pipe, the start of the left side), with the position path of the module the call is in. A written argument is used as written.caller()anywhere else, or used as a function value, is a compile error; afn callerthe module declares shadows it, and= caller()is then an ordinary default.- Passing on is explicit. Each hop writes
at: at; a function with noatparameter reports the line in its own body. There is no implicit caller location (reasons indocs/caller-location-design.md). - For a value of the current location (to compare in a test, or to supply when calling through
a function value):
loc.here(), which is itselffn here(at: Loc = caller()) -> Loc = at. - The last parameter of
panic,todoandexpectisat: Loc = caller(). With it omitted the message is exactly §8.2's. - std has no position path, so a
caller()omitted in std is aLocthat names no place (rendered as the empty string;panicadds no suffix for it). std only takes aLocfrom its callers and never hands one of its own back to a program. - At comptime nothing changes: the location is a constant before anything is evaluated.
9.Java interop#
9.1Importing and calling#
use java "java.nio.file.Files"
use java "java.nio.file.Path"
use java "java.lang.StringBuilder"
fn slurp(p: String) -> Option[String] !io =
Files.readString(Path.of(p).expect("valid path"))
fn build() -> String !io = {
let sb = StringBuilder.new() # constructors are always spelled .new
sb.append("a")
sb.append("b")
sb.toString().expect("non-null")
}
use java "fully.qualified.Name"imports the class as: one opaque type plus one namespace of static methods. Read-only static fields are accessible:Class.FIELD(constants are upper case by convention, e.g.Integer.MAX_VALUE,Math.PI, the enum constantTimeUnit.SECONDS; a lower-case field name is just as readable, e.g.System.out) reads that field — the value is mapped by §9.2 (a reference type is wrapped inOptionand needs!to unwrap, primitives such asint/doubleare widened), and the effect, as for all interop, is!io. Read-only (there is noClass.FIELD = v); instance fields and non-pubstatic fields are not accessible.- The member name after
.may be any "word", including names that coincide with Dawn keywords (in/type/matchand so on): Java has members named those, and after a.a keyword is unambiguous and is always taken as a member name. SoSystem.in(the field nameinis a keyword) andobj.type()(the method nametype) can both be written directly, with no detour through reflection. - A member name is matched against the JVM declaration exactly; case does not decide
what kind of member it is.
Class.memberreads a public static field,Class.member(args)calls a public static method, andvalue.member(args)calls a public instance method — a field and a method of the same name (Java's two namespaces may collide) are told apart by whether there is a(...)suffix. SoMath.IEEEremainder(a, b)is a method call, not a field read. A wrong case is not folded (Math.ieeeremainder(a, b)reports "no static method"), and staticness is part of the match as well: a static method cannot be called through an instance, nor an instance method through the class. Dawn's own qualified spellings win over the Java lookup:m.C(args)is a qualified constructor, and whenmis a module alias the whole thing is resolved by §10.3 (a qualified constant with(...)still reports that it is not callable). - Constructors are uniformly
Type.new(args)and returnTitself (a constructor never returns null, so it is not wrapped in Option); instance methods use.method(args). - Every Java call has effect
!io, without exception (the reasoning is in design.en.md D5). - The return value of a Java call may be discarded (in statement position, or in the
tail position of a Unit block) — Java APIs often return
thisor a status code; the "must not be silently discarded" rule only protects Dawn values. - When an unimported reference class turns up in a return value (such as the
PathofPath.of), the value is still usable (it automatically becomes an opaque type and can still be chained on);use javais only needed in order to write the type name in a signature. - Class resolution happens by reflection at compile time: JDK classes are always
visible; third-party classes may come from the project's
[java-deps](dawn check/doc/run/test/build), or from--cp <jars>(common todawn run/test/build, §12.1). Commands that run a program use the same classpath for compilation and execution. LSP currently resolves only JDK classes; a third-party class is reported as not found in the editor but compiles on the command line. - Nested classes are written with a dot:
use java "java.net.http.HttpResponse.BodyHandlers"(not$—$inside a string is taken as interpolation). Resolution first reflects on the whole name, and on failure retries with.replaced by$one at a time from right to left, so any nesting depth can be written with dots; the bound name is the last segment (BodyHandlers). The generic methods of a nested class still return an opaqueObjectafter erasure (§9.2) —HttpResponse.body()for instance returnsObject, and a string body is bridged back withString.valueOf(...).
9.2Type mapping#
| Dawn | Java | Direction |
|---|---|---|
Int | long (an incoming int is widened automatically) | both ways |
Float | double | both ways |
Bool | boolean | both ways |
String | java.lang.String | both ways |
Unit | void | return |
an imported class T | a reference to that class | both ways |
A Java method returning a reference type is always Option[T] — null is stopped at
the boundary. Unwrap with ! (§8.2) or handle it with match:
use java "java.net.URI"
use java "java.lang.StringBuilder"
pub fn main() -> Unit !io = {
let uri = URI.create("https://dawn-lang.org/spec")! # method: wrapped in Option, unwrapped
let sb = StringBuilder.new() # constructor: not wrapped, the object itself
sb.append(uri.getHost()!)
println(sb.toString()!)
}
dawn-lang.org
Why methods are wrapped and constructors are not — these are not two arbitrary rules; each has its basis:
- Constructors are not wrapped: the JLS guarantees that a
newexpression never returns null, so wrapping it inOptionis pure noise. - Methods are wrapped: a method can return null, and the compiler has no way
to tell statically. JDK classes carry no nullability annotation visible at runtime —
URI.create(never null) andMap.get(genuinely nullable) both reflect out with nothing. Since they cannot be told apart, everything is wrapped: better to let!carry the risk explicitly than to let null into Dawn.
Primitive return values are not wrapped in Option; a short/byte/int return is
widened to Int automatically, float to Float. char in argument and return
position is currently unsupported; arrays go through as opaque values (§9.5). Passing
null for an Option argument is unsupported as well (Dawn currently cannot pass null to Java).
9.3Overload resolution#
A unique candidate is picked by scoring on "argument count + static type" (an exact
match on long/double beats narrowing to int/float, String beats
CharSequence/Object); a tie for the highest score, and no candidate at all, are both
compile errors (the message lists the candidate signatures). A function-value argument
only matches a functional-interface parameter (§9.4); a Dawn List argument can match a
List/Collection/Iterable parameter (§9.6); an exact array match beats widening to
Object (§9.5).
Varargs follow the JLS in two phases: one round without packing first (phase 1), and only if all of that fails, packed as varargs (phase 2); phase takes precedence over score — the score is summed per argument and grows with the argument count, so without phases a packing candidate would overtake an exact match. The variable part is spread out inline, as it is written in Java:
use java "java.nio.file.Path"
use java "java.util.List"
use java "java.lang.String"
pub fn main() -> Unit !io = {
let p = Path.of("a", "b")! # one trailing segment: packed into String[1]
let q = Path.of("a", "b", "c")! # same phase, String[2]
let l = List.of("a", "b")! # phase 1 wins: of(E, E) is chosen
let m = List.of("a", "b", "c")! # phase 2: packed into E[]
let e = List.of()! # no variable part = pack 0 = empty array
println("${p.toString()!} ${q.toString()!}")
println("${String.join(",", l)!} ${String.join(",", m)!} ${to_string(e.size())}")
}
a/b a/b/c
a,b a,b,c 0
The variable part takes Java references too (BodyPublishers.concat(head, file, tail)!).
The trailing arguments are scored one by one against the array component type, by
the same rules as an ordinary parameter, so SAM conversion (§9.4) and the List bridge
(§9.6) are equally available inside the variable part. Passing a ready-made array as
the variable part (a String[], say) goes through phase 1 and is passed as is, not
repacked. Note that scalars are not boxed (§9.2), so Object... takes String and Java
references but cannot take Int/Float/Bool; char is unsupported in argument
and return position, and char... with it.
9.4SAM conversion: function values across the boundary#
use java "java.lang.Thread"
fn spawn_hello(msg: String) -> Unit !io = {
let t = Thread.new(() => println(msg)) # Dawn lambda → java.lang.Runnable
t.start()
t.join()
}
- When a Java parameter is a functional interface (an interface with exactly one
abstract method; the public methods of
Objectdo not count), the argument may be a Dawn function value — a lambda, a named function or a constructor value all work. Interfaces only; an abstract class with a single abstract method is not supported. The implementation generates an ordinary adapter class that holds the DawnFnNand writes it into the classfile at build time; native-image needs no runtime dynamic-class configuration. - Matching: the SAM method signature is mapped to a Dawn function type by §9.2 and
then matched as usual; a lambda's parameter types can be seeded from the parameter (the
same mechanism as generic argument inference). In overload scoring a function value
only matches functional-interface parameters. Dawn does not track Java generic
arguments, so the parameters of a generic SAM (
Predicate/Functionand the like) enter Dawn with their erased types (usually an opaqueObject, which can only be passed along as is); only a SAM with concrete types (Runnable,HttpHandler) gives the full experience. - Every row but
ctlcrosses, and the evidence comes from the creation point: a row carrying a named effect label, an effect variable or an associated-effect projection can be handed out. The conversion point snapshots the evidence pack that is in scope there into the adapter object, and Java's call hands it to the closure; this is §6.5's boundary clause. The reason is there too: Java enters a callback from somewhere with no Dawn frame under it, so there is no call site to supply evidence at, and the creation point is the last place any exists. ctlis the one exception: a function value whose row names actleffect (§6.5) does not cross, and the conversion point is a compile error, becausectlsays exactly that an arm may suspend this value's caller, and there is no Dawn frame under a Java frame to suspend into. The ways out are to move the conversion elsewhere, or to drop thectlfrom the declaration when no arm of it binds a continuation. Only written labels are judged this way: on the effect-variable and associated-effect-projection axes the conversion point cannot know what the caller will instantiate!ewith, so those are caught at run time, where a suspension nothing can take is a panic rather than a wrong answer.- The row is charged at the conversion point: a crossing row counts towards this function's row like any other call site. A label nothing in scope answers still reports "nobody is handling this"; when this function's own signature carries it out instead, what the snapshot reads is this frame's hidden evidence parameter. So the boundary clause changes where evidence comes from, not whether it has to exist.
The snapshot is the handler that was in scope when the value was written: Java calling after the frame that installed the handler has returned still reads that one. Writing
with handle E { ... }inside the function value is a different thing: it installs a fresh handler on every run, over the top of the snapshot.with handle Log { log(m) => println(m) } Thread.new(() => log("tick")) # the snapshot carries this handler Thread.new(() => { # installs one of its own on every run with handle Log { log(m) => println(m) } log("tick") })- When the call happens is still untracked: the effect system does not care when the
Java side calls; Java may call that function value on any thread at any moment
(including after this call has returned). This does not break the purity contract: Java
code can only run underneath a Dawn
!iocall, or on a Java thread with no Dawn stack, and no signature promise of a pure or!iofunction is violated. The snapshot brings a labelled row under the same sentence: whenever it is called, what answers it is the handler chosen at the conversion. - The null boundary for parameters: a callback's reference-typed parameters are
not wrapped in
Option, they arrive asT; the bridge layer checks each one, and a null passed in by Java panics immediately (the message names the callback boundary). This is complementary to wrapping return values inOption(§9.2): null in return position is normal and therefore goes into the type, null in a callback parameter is pathological and therefore fails fast. - Narrowing return values: when the SAM method wants a
byte,shortorintand the Dawn function returnsInt, a checked narrowing is done — values outside the target signed 8-, 16- or 32-bit range panic, they are not silently truncated; when it wants afloat,Floatis narrowed by the IEEE rules (precision may be lost, which is floating-point semantics, not overflow). - A Dawn function that panics inside a callback is handed to the Java caller as
dawn.rt.PanicError(a subclass ofError), neither caught nor wrapped.
9.5Arrays: opaque pass-through; byte[] = first-class Bytes#
An array value is treated the same as an unimported reference class (§9.1): it can be
received, held and passed on — overload scoring matches the array type exactly, or
widens to Object; in return position it is wrapped in Option per §9.2. But an array
(except byte[], see below) is unnameable (the type cannot be written in a
signature), cannot be created and cannot be indexed; for its length use
Array.getLength(a) from use java "java.lang.reflect.Array".
byte[] is the one exception: it is the first-class type Bytes (§9.5.1). A
concrete byte[] returned by a Java method (readAllBytes/toByteArray/Base64.decode
/MessageDigest.digest) lands as Option[Bytes] per §9.2; Bytes can be written in a
signature, stored in a record, sliced/indexed/concatenated/compared by content; passed
back the other way it matches a Java byte[] parameter (OutputStream.write,
MessageDigest.isEqual and so on) directly.
use java "java.nio.file.Files"
use java "java.nio.file.Path"
fn slurp(p: String) -> String !io = {
let bytes: Bytes = Files.readAllBytes(Path.of(p).expect("path")).expect("readable")
decode(bytes, "UTF-8")
}
9.5.1Bytes: a first-class immutable byte sequence#
Bytes is an immutable sequence of bytes, at runtime a bare byte[]. The library
functions (§11, the "bytes" group): utf8(s) -> Bytes (the UTF-8 bytes of a string),
decode_utf8_lossy(b) -> String / decode_latin1(b) -> String (decoding, see §11),
decode_utf8_checked(b) -> Result[String, Utf8Error] (strict decoding, see §11),
len(b), bytes.at(b, i) -> Int (0..255, out of range panics),
bytes.slice(b, start, end) ([start,end), subscripts clamped into range),
bytes.index_of(b, needle, from = 0) -> Option[Int]. index_of clamps a negative from
to zero; a non-empty needle is searched from that byte offset for its first complete
match. An empty needle matches at every valid position in [0, len(b)] (so
from == len(b) returns Some(len(b))), but from > len(b) returns None even for an
empty needle. Bytes ++ Bytes concatenates,
==/!= compare by content (Show renders a <N bytes> summary). The hash of
Bytes is a content hash (seed 1, byte by byte h = 31*h + the signed byte,
wrapping at 32 bits, see §3.5 — the same shape as the composite rule there), consistent
with == on content, so Bytes can be a Map/Set key. (It was once forbidden because
the JVM hashCode of byte[] is reference identity; after both ends were changed to
content the ban did not get withdrawn along with them, and was withdrawn on 2026-07-27.)
Bytes does not take part in comptime constant folding, and cannot be a bare first-class
function value either (wrap it in a lambda).
An erased Object may be claimed only explicitly. The return of an erased generic
(§9.2) lands as an opaque Object. Overload resolution uses that static type only through
ordinary Java assignability: it can be passed to an Object parameter, but it cannot match a
concrete reference parameter such as Path, InputStream, or byte[] in the other
direction. The compiler inserts no hidden CHECKCAST at the argument bridge; the same
conversion cannot fail as a value when written explicitly but escape through a host exception
when inserted implicitly.
If you know for certain that some opaque Object from an erased generic is at runtime a
particular concrete reference type (such as byte[] when HttpResponse.body() is paired
with BodyHandlers.ofByteArray()), use the generic builtin
cast[T](x: Object) -> Result[T, ForeignError] to claim it as that type (T is taken
from the expected type at the call site, e.g.
let b: Result[Bytes, ForeignError] = cast(...)) — the claim does one runtime
check, and a type mismatch is an Err, not an exception passing through; for the
payload see §9.8.1 (on the JVM the kind is java.lang.ClassCastException). T must be a
reference type (a primitive, or no expected type, is rejected at compile time). Java array or
parameterized targets that cannot currently be named in Dawn get no implicit exception either;
when a real use case needs one, the surface must first gain a writable target type.
A function with a pure signature should not be able to exit through a host exception (LANG-02) —
castused to throwClassCastException, which is exactly the exit a pure signature is supposed to exclude. Failure is now a value. The three stages the migration went through (including a transitional spelling that lived for exactly one release) are recorded in `error-model-design.md` §6.10–§6.12, and it is closed.
9.6The List bridge: a Dawn List reaches a collection parameter directly#
When a Java parameter is declared java.util.List / java.util.Collection /
java.lang.Iterable, the argument may be a Dawn List[T]. The bridge is a copy, not a
view: a Dawn List is a pure-Dawn persistent vector (std/pvec) that Java has no name
for, so every crossing copies the elements, in order, into a fresh java.util.ArrayList
and hands Java that list wrapped in an unmodifiable view (Collections.unmodifiableList).
The mutating methods on the Java side throw UnsupportedOperationException; a reference
Java keeps sees none of the versions the Dawn side makes afterwards.
- Cost: each crossing is O(n) time and O(n) allocation in the number of elements, and it is not amortised across calls: passing the same Dawn list twice copies it twice. Measured on the JVM: about 8 ns per element (about 8.4 ms per crossing at 10⁶ elements). When the same large list goes to Java repeatedly in a loop, hold the bridged result once on the Java side and reuse it.
- The element type
Tis limited to:Int/Float/Bool/String/ an imported or opaque reference class. An element that is aList/Map/Set/ADT/tuple/record/ function value is rejected (a compile error). The copy converts only the outer level, so an inner value would reach Java in Dawn's own representation (such as astd/pvecvector node), which Java cannot read; there is currently no deep conversion. - Elements arrive in the boxed representation of §9.2 (
Int→java.lang.Long). Generic erasure means an API expectingList<Integer>willClassCastExceptionwhen it reads them; the current bridge does not repair that, so pick your APIs with care. - The direction is Dawn → Java only; a collection returned by Java is still an opaque
reference plus
Option(§9.2), and can be chained on, but it is not converted back to a DawnList(annotating it asList[T]is a type error). AMap/Setbridge is not currently provided.
9.7Limits#
Java classes cannot be inherited from; Java interfaces cannot be implemented as a named
class — handing a function value out through SAM conversion (§9.4) is the only path.
Read-only static fields are accessible (Class.FIELD, §9.1) — enum constants and static
constants are read directly (TimeUnit.SECONDS, Integer.MAX_VALUE); writing a static
field, and instance fields, are still unsupported. Arrays cannot be created, indexed or
named (§9.5); Map/Set are not bridged, and Java collections are not converted back
into Dawn values (§9.6); passing null for an Option argument is unsupported (§9.2).
9.8The foreign-failure barrier: catch_fault#
Up to v0.30.0 this builtin was called
java_try; v0.31.0 renamed it. What it intercepts is a fault — a failure caused by the outside world — and ever since native grew failure kinds that classification has been shared by both backends (`native-backend-plan.md` §14.9) and has nothing to do with Java; the name outlived its reason by a while. The old name's migration hint was withdrawn together with thestd/moved.txtmechanism (`std-moved-design.md`), so writingjava_trytoday gets the ordinary undefined-function diagnostic.
Dawn has no exceptions: an exception thrown by a Java call passes through unchanged by
default and terminates the program (panic semantics). But an expected foreign failure
(the network drops, a SQL constraint is violated, a parse fails) is expressed in the Java
world as an exception; those are not bugs and belong in a Result. The builtin
catch_fault is the one conversion point:
use java "java.lang.Long"
fn parse(s: String) -> Result[Int, ForeignError] !io =
catch_fault(() => Long.parseLong(s))
# Err(ForeignError { kind: "java.lang.NumberFormatException", message: ..., cause: None })
- Signature
catch_fault[T, !e](f: fn() -> T !e) -> Result[T, ForeignError] !e. The protected closure's row is an effect parameter!e: the closure may be pure, may be!io, and may carry a label or an effect variable, as long as those effects have a handler answering them outside the barrier. The whole call's row is that row — the barrier adds nothing of its own to it. Its companioncatch_panichas the same shape (the two differed between 2026-09-01 and 2026-09-24); the reason is at the end of §9.8.1 and in `docs/effects-window-design.md` §4. - It only intercepts
java.lang.Exceptionand its subclasses;Erroris not intercepted — a Dawn panic (dawn.rt.PanicErroris a subclass ofError) passes through unchanged, a panic is still a bug and is not recoverable. - The
Errpayload is aForeignError— a prelude record whose fields and values are in §9.8.1. Up to v0.32.0 it was a single rendered string (Throwable.toString()), and this section also used to advise "match the string by prefix when you need to tell exception kinds apart"; that advice is withdrawn, andkindis its replacement. - Failures inside the boundary propagate as usual: wrapping
catch_faultaround a whole compound call is enough, there is no need to wrap call by call.
The companion catch_panic[T, !e](f: fn() -> T !e) -> Result[T, ForeignError] !e has the
same shape: the protected closure's row is the whole call's row, and the barrier adds no
effect of its own (the end of §9.8.1 puts the three barriers side by side). It intercepts
two kinds, a Dawn panic (PanicError) and Exception — not any Throwable: VirtualMachineError (heap
exhausted, stack overflow) passes through, resource exhaustion is not a value. It is for a
supervision boundary — one request on a server, one execution of a task runner: a
panic in one request should become a 500 and be logged, rather than take down the whole
connection or process. Its division of labour with catch_fault is clear: catch_fault handles
expected foreign failure and lets panics through; catch_panic is an isolation
point. Ordinary business failures still go through Result — do not use catch_panic
as routine error handling.
This division of labour is backend-independent. The JVM gets it for free from the class hierarchy (
ErrorversusException); native has no exceptions, every failure travels the samelongjmp, so a failure carries a kind and a handler remembers whether it takes panics. The criterion is one and the same: a failure the language defines itself is a panic (panic,expect, an out-of-range subscript, division by zero, an illegal code point), a failure caused by the outside world is not (the io primitives — counted up, that is the only such class). The measured comparison of the two backends is inscripts/spike-native/catch_kinds.dawn; before it was written, native'scatch_faultintercepted every panic that should have passed through.
9.8.1The payload ForeignError#
"Match the string by prefix" used to be this section's advice and is now withdrawn: it builds control flow on one piece of text that can be refactored, localised, or changed by the next JDK. The payload is a prelude record:
type ForeignError = { kind: String, message: String, cause: Option[String] }
kindis the name the backend itself gives to that class of failure, and it is a name, not a rendering: on the JVM it is the binary name (getClass().getName(), e.g.java.lang.NumberFormatException,dawn.rt.PanicError), on native it is the runtime's failure kind ("panic"/"fault").- Code that dispatches on
kindis backend-dependent code. The only portable match is theOk/Errlevel. The table of values and the reasoning are indocs/runtime-intrinsics-design.md§12.4. messageis what the failure says about itself (the JVM'sgetMessage(), the empty string if there is none);causeis the rendering of the failure underneath,Noneif there is none. No stack: rendering a stack costs something at every single barrier.
The payload contract (on every backend):
messagehas no length limit and equals, byte for byte, the String the failure was raised with; a failure the language itself raises has a byte-identical message on both backends —panic(m),assert, an out-of-bounds index, division by zero, an invalid code point,expect, without exception (pinned by differential corpora inscripts/spike-native/such asdivzero_messages,index_panic_textandcatch_kinds). Setting a limit means picking a number, and any number will one day be crossed by a legitimate message; truncation would also have to handle character boundaries — so this is "no truncation clause", not "a generous limit".messageis well-formed UTF-8. A direct consequence of the String invariant (§4.8), written out separately because it has been violated: a payload cut at a byte count landed mid-character.- A failure's payload belongs to the barrier that is going to take it: from the
raise until that barrier builds the
ForeignError(orbrackethands it to the next barrier out), no other barrier can read or write it. A nested catch therefore cannot affect a failure in flight (the unfolding of §9.8.2 guarantee 2). - The panic/fault split is observable — by which barrier takes the failure, not by
the
kindstring (whose values remain the backend's own).
There is only this one pair of barriers (only this pair intercepts failure; the
bracket of §9.8.2 intercepts nothing), only the ForeignError payload, and the
String version is not kept.
This pair's effect row is one row: the protected closure's, with nothing added by the barrier. The promise of a pure function is the same arguments give the same value, within one program and one build (§6.2 rule 4), checked against which failure is observed:
- A fault is a failure caused by the outside world, and every route out there is
charged before it can raise one (an io primitive and a
use javacall are both unconditionally!io). So whoever can reach thatErris impure already, and a second charge from the barrier is one nobody owes. - A panic is a failure the language defines itself, and its arguments decide it:
evaluation is strict and its order is fixed, so whether a pure closure panics on given
arguments, and with what message, is settled, folding or deduplicating pure calls cannot
change what is caught, and how many times the catch happens is not observable. Source
text inside a message (an
assert's expression) and the call-site location (§8.2, which depends only on the source tree) are part of the program, as a string literal is; the backend part of a message is excluded by the payload contract above (byte-identical on both). Comptime still refusescatch_panicoutright (§7), so the compiler never folds a build machine's message into an artifact.
So the three barriers share one row: the bracket of §9.8.2 observes nothing,
catch_fault observes a failure io has already been charged for, and catch_panic
observes one its arguments decide. All three carry the variable.
The line rests on an invariant: a fault only comes from io. It used to carry one named exception: a continuation's row was once unconditionally pure, so a function with a pure signature that resumed or discarded one brought the remainder's io, and its faults, back into its own frame (both backends were measured doing this). Since 2026-09-02 a continuation carries its remainder's io bit (§6.5 "Control arms"): a remainder that can fault hands out an
!iocontinuation, no pure frame can call it, and the exception is gone.kis still an ordinary function value with no type of its own; what changed is the io bit of its row and nothing else (`docs/oneshot-design.md` §11.2, postscript). The one shape left is a remainder whose only effects are labels, where a label's arm does io at its own installation point: that is the standing supply-point doctrine (the boundary clause under §6.5 "Implementation", the same one the SAM snapshot rests on), the arm's io is charged to the frame that installed it, and it is not an exception to this clause.
The full argument, and the condition under which it reopens, is in `docs/audit/error-model-design.md` §7.
History: moving the payload from String to
ForeignErrortook three releases, because a builtin's signature is bound by seed discipline just as its name is, and more tightly — a rename can have both tables know two spellings within one stage, a payload type cannot: the compiler's own call sites cannot satisfy aResult[T, String]table and aResult[T, ForeignError]table at the same time. So the new shape landed first under the transitional spellingscatch_fault_e/catch_panic_ewith zero call sites (v0.32.0); one release taught the previous generation of the compiler about them; the call sites moved and the entries under the original names were flipped (v0.33.0); then the call sites moved back to the original names and the transitional spellings were deleted (this release). That pair of names appeared indawn doc --builtinsfor v0.32.0/v0.33.0 and does not exist after that. The staging is indocs/audit/error-model-design.md§6.
9.8.2Releasing, not intercepting: bracket#
Dawn has no try/finally, and does not intend to (the pair of barriers in §9.8
intercept, they do not release). "Whichever way you leave, hand the resource
back" is carried by a third builtin:
fn bracket[A, B, !e](resource: A, release: fn(A) -> Unit !e, body: fn(A) -> B !e) -> B !e
The third parameter is named body: use is a keyword, so the call
bracket(r, close, use: f) cannot be written at all, and that spelling never had a
caller -- it only ever appeared in the rendered signature.
let f = FileOutputStream.new(path) # acquire the resource: ordinary code, before the call
bracket(f, s => s.close(), s => write_all(s, bytes))
The resource is a value, not an acquire closure. Haskell's bracket takes a thunk in
order to close the window for asynchronous exceptions — another thread or a timer could
interrupt between "acquired" and "handler installed". Dawn has no such thing: a failure
is only raised from code the program itself calls, and between evaluating the arguments
and this builtin installing its handler none of the caller's code runs, so a thunk closes
no window at all (Koka's finally is the same, and likewise takes the resource
directly). Acquiring the resource is therefore ordinary code before the call — a
failure there needs no release, because nothing has been acquired yet.
It comes last to leave the road open for with (§4.10, landed 2026-07-31): that
sugar attaches "the rest of the block" as the last argument, so a primitive holding
it in the middle would have to be respelled. Once the resource is acquired up front,
such a site does not need a single lambda:
with f <- bracket(open(path), close)
...the rest of the block is the body...
Three guarantees:
releaseruns exactly once on every path —bodyreturning normally, panicking, faulting; all three run it, and run it only once.The original failure keeps propagating unchanged:
kind/messageverbatim, a panic is still a panic and a fault is still a fault. Socatch_faultstill does not intercept a panic that passes through bracket (on native that kind bit is restored by re-raising, not re-inferred; the measured comparison of the two backends is inscripts/spike-native/bracket.dawnandbracket_fatal.dawn). A failurereleasecatches for itself does not affect the one in flight — raising and swallowing a new failure inside a release is legal code, and the crossing failure comes out as it went in (scripts/spike-native/bracket_release_fails.dawn). A failure that escapes the release itself (raised and not caught) replaces the original, with no suppressed chain: that is what both backends do today, written here so it is a ruling rather than a coincidence.This replacement rule has exactly one exception: the unwind that discards a continuation (§6.5's
discard). What is passing through on that path is not a failure but a poison, and a poison is by definition uncatchable; letting arelease's failure replace it would turn it into an ordinary failure, so acatch_panicinside the frozen span would take it and the abandoned computation would be resurrected out of its own cleanup and run to the end. So a different rule applies there, Lua's__closerule: the walk is not stopped and the remainingreleases still run; the first failure is the one delivered, handed to whoever calleddiscardonce the unwind is finished. The corpus isrelease_failureinscripts/spike-native/ctl_discard.dawn.bracketintercepts nothing, so it returnsBand notResult— guarding and intercepting are two orthogonal things (neither Haskell'sbracket, Kotlin'suse, Koka'sfinallynor Go'sdeferreturns a Result). To take the failure as a value, writecatch_fault(() => bracket(...)); each of the two primitives does one thing.- The effect row is a variable
!e:release,useand the call as a whole share one row, andbracketadds no effect of its own. So abracketover a pure resource is pure, an!ioone is!io, and a labelled one passes its label straight through. The pair of barriers in §9.8 binds an effect parameter too (catch_fault[T, !e]), so a free row on the closure being run is common to all three, and so is the primitive's own row being that variable (catch_panic's was pinned to!iobefore 2026-09-24). The reason is at the end of §9.8.1 and in `docs/effects-window-design.md` §4.
It gets no surface syntax like
defer: the protected region is always one closure call, soreturn/?/breakcannot cross out of it at the language level, and the compiler owes no escape rewriting. The criteria, and the earlier conclusion they overturned, are indocs/core-move2-design.md§2.6 and §6.
10.Module system#
10.1Files and module paths#
One .dawn file = one module. The module path = the path relative to the module root
with the extension dropped: <root>/json/lexer.dawn → module json/lexer. Every segment
of the path must match [a-z_][a-z0-9_]* (the same as the file name), otherwise it is a
compile error.
How the module root is determined:
- Directory mode
dawn check|doc|run|test|build <dir>: root =<dir>/src, entry =<dir>/src/main.dawn(if it is missing, the compiler reports it and prints the expected path). - File mode
dawn check|doc|run|test|build <file.dawn>: starting from the file's directory, walk up to the nearest ancestor directory namedsrcand take that as the root; if there is none, the root = the file's own directory. The LSP uses the same heuristic, so opening a single submodule file on its own still resolves itsuses relative to the root.
The directory convention is the project definition: the module root, the entry and the module paths are all decided by the directory structure; no manifest file is needed.
A project may optionally carry a dawn.toml, which holds only what the directory
convention cannot express — project identity and dependencies. A project without one works
exactly as described above. The contents of schema 1:
schema = 1 # must be the first key
name = "backend_dawn" # project identity ([a-z_][a-z0-9_]*)
[java-deps] # Maven deps, for `use java`
sqlite = "org.xerial:sqlite-jdbc:3.36.0.3" # exact coordinates; no SNAPSHOT, no ranges
[deps] # Dawn source packages: alias = local dir
web = "../packages/web"
[deps.json] # or a remote archive (zip / tar.gz)
url = "https://github.com/dawnop/dawn-lang/archive/refs/tags/v0.7.0.zip"
version = "1.0.0" # strict x.y.z; version solving = MVS
hash = "d1:<sha256>" # content hash of the unpacked file tree
subdir = "packages/json" # package root inside the archive (optional)
An alias in [deps] is only how this side's source spells it (use <alias>/<module>);
a package's identity is the name in its own manifest — the class-name namespace, version
solving and one-name-one-copy across the whole program all go by the real name, and an
aliased import is normalised to the real name at load time. dawn add <coordinate|url|path>
can write these entries for you (fetching the archive and computing the hash, preserving
hand-written formatting).
One-name-one-copy compares directories: a path dependency reached through a symbolic link and
through its real path is one directory, and so one package (directories are compared by their
physical path with links resolved, falling back to the lexical spelling for a path that does
not exist), and a .. steps to the physical parent, as the kernel does. Module paths are not
affected and stay the spelling below the module root: a link inside src/ gives one file two
module paths, and those are two modules.
dawn check|doc|run|test|build fetches [java-deps] (including those declared by each
dependency package — the union) for compile-time use java resolution. Each target of
check/doc gets an independent classpath; run/test/build instead merge it with
--cp and use the result for both compilation and execution. dawn build additionally
copies [java-deps] into a lib/ next to the jar. The repository address comes from
$DAWN_MAVEN_MIRROR and does not go into the manifest.
A manifest is always data, never code — there is no executable build.dawn. The
reasoning and the full design are in `package-design.md`.
10.2Imports#
use json/lexer # whole-module import; alias = last segment lexer, qualified access lexer.next(...)
use json/lexer as jl # explicit alias, qualified access jl.next(...)
use json/value.{Json, render} # selective import, used unqualified
use java "java.lang.Math" # Java interop (§9), form unchanged
- A module's whole-module import and its selective import may each appear at most once, and the two may coexist — one binds an alias, the other binds names; they are two different acts. The same form twice is an error (two selective imports count even when the braces name different items).
usemay appear anywhere at the top level (the same asuse java), anddawn fmtdoes not reorder them.asrenaming: a whole-module import can name its alias explicitly withuse a/b/c as name(the default alias = the last segment); each entry of a selective import can be writtenName as Local(use json/value.{Json as J, render as show_json}), and that holds for every exported member alike — functions, constants, types, constructors,aliases, traits and effects.asis a contextual keyword (special only after a whole-module path and after a selective-list entry, not a reserved word, still usable as an ordinary identifier). If two whole-module imports share a last segment, giving them differentasaliases lets them coexist (otherwise the same last segment → error).- The renaming rules: the module binds only
Local, andNameis not visible;Localmust be of the same case class asName(case is meaning, §1), andName as Nameis an error. A rename changes this module's name table only: the imported member's identity, the names it brings along (a type's constructors, an effect's operations) and the name diagnostics print all stay the same; to rename something brought along, list it and rename it on its own. Clash checks use the local name. - An unused import is a compile error (
unused import: <name>), not a warning — Dawn has one diagnostic level. It is decided per name: a whole-module import by the alias it binds, a selective import name by name (a renamed one by its local name, reported as the exported name the author wrote),use javaby the class name it binds; a use inside a test block counts as a use. An imported type can be used through its constructors and an imported effect through its operations (unless that constructor or operation is itself imported by name). The rule stays silent while the module has any other diagnostic; there is no "import for side effects" exemption, because a module has no initialization side effects and an impl does not needuseto take effect. The reasoning and precedents are in `unused-imports-design.md`.
10.3Name resolution (disambiguation rules)#
- The alias of a whole-module import lives in the same namespace as this module's
top-level declarations, local bindings and parameters: declaring any top-level
fn/type/const, local or parameter with the same name as a module alias is a compile
error ("
lexershadows the imported modulejson/lexer"). Hencelexer.next(x)is never ambiguous —lexeris either a binding (a UFCS dot call, §4) or a module alias (qualified access), and it can never be both. - Qualified access supports
alias.fn(args)in expression position (calling a pub function;alias.fnin callee position, as inx |> alias.fn,(alias.fn)(x)orwith v <- alias.fn, is the same call, andalias.fnin any other expression position is a function value, §4.4) andalias.T[...]in type position (LANG-06, 2026-07-30): a lower-case name in type position can only be a module alias, so there is no ambiguity; it may point at an exported ADT/record or apub alias(the latter expands according to the declaring side's resolution). Pattern position likewise supports qualified constructorsm.C(..)/m.Pt { .. }: a lower-case name followed by.Uppercasein a pattern can only be a module alias. Qualified constantsm.NAMEand qualified constructorsm.C(..)/m.C(expression position) are available too (same day): this module may have a constant or a constructor of the same name and the two do not interfere — in Core the identity of a constant is (declaring module, name), not the name; a qualified constructor goes through the same checks as the unqualified spelling, so field names, arity, spread and generic instantiation all behave identically. - Selectively importing a
typealso imports all of its constructors (matching the rule thatpub typeexports the constructors and fields). - A selectively imported name clashing with a top-level declaration in this module or with
another import → error. Clashing with a prelude name: a selective import may
shadow a prelude name (you asked for that name verbatim, the intent is clear),
including the method names of prelude traits; top-level fns / trait methods /
effect operations may likewise shadow builtins, std and prelude trait methods
(Rust-style) — the resolution order is this module's declarations → std → builtins.
Shadowing applies only to that one spelling: the shadowed spelling is unreachable in
that module, and that is the declarer's own choice. Operators and sugar (
==,${...},for..in) find their impl by trait, not by name, and are therefore unaffected by shadowing — shadowingshowdoes not change what${x}prints, shadowingiter_nextdoes not change howfor..inbehaves. But the type / trait names of the builtins and the prelude (Map/Option/Ord…) still cannot be redefined.
10.4Visibility#
All declarations are module-private by default; pub exports fn/type/alias/const/trait/effect
(pub type brings the constructors and fields with it, see §3.3). Accessing or importing a
non-pub item → error (`parse` is private to module json/parser, with a hint: add
pub). A non-pub item of the standard library is the exception: std is not the
user's to edit, so the error says the item is not part of std's public API (`atoi` is not part
of std's public API (`std/fmt` declares it privately)) with no "add pub" hint (#296). An exported declaration must not leak a private type, trait or effect that cannot be named
outside the module either; the full rules for transparent aliases, the opaque boundary, public
traits/effects and reachable impls are in §3.3, and the error is reported at the declaration rather
than at the use site.
Package visibility pub(pkg) (2026-09-24, design): there are
three visibility levels — module-private (nothing written), pub(pkg), and pub. pub(pkg) may be
written everywhere pub may, and means the same except for the boundary: a pub(pkg) declaration
is visible to every module of the same package (one dawn.toml unit) and invisible outside it. A
package is one source unit as the loader sees it: the project being checked (with or without a
dawn.toml), each [deps] source package (by the real name in its manifest), and the bundled
standard library as a whole. Importing or qualified access to a pub(pkg) item from outside the
package → error (`seam` is package-private to package `web`).
pkgis a keyword only afterpub(; anything afterpub(other thanpkg)is an error (Dawn has nopub(super)orpub(in …)). Apub(pkg) fn, like apub fn, must state its return type.- Public surface: a
pubdeclaration's signature may not mention apub(pkg)type, trait or effect (public function `f` exposes package-private type `T`); apub(pkg)declaration's signature may mentionpub(pkg)identities of the same package and may not mention module-private ones. The decision is the table of the public surface rules in §3.3, with one more level. dawn docoutput containspubitems only;pub(pkg)items are not part of the public surface.- Visibility is a check-time notion only: after lowering, Core and both backends do not distinguish the three levels.
- Fields have no visibility of their own: a record field is visible wherever its type is. Hiding a
representation is what
opaque type(§2.7) is for.
Load scope (2026-07-30, LANG-07):
dawn run/test/build <dir>loads every module undersrc/by default — modules that are never referenced are checked too (bit-rot protection, and that is the right default).--closurenarrows it to "the use closure of the entrysrc/main.dawn", for large projects producing an artifact;dawn checkis always whole-repo. Recommended for CI:dawn checkguards the whole repo anddawn build --closureproduces the artifact.dawn checkexits 1 on any diagnostic (§12.1), so the exit code alone is enough to act on that recommendation.
10.5Compilation units and evaluation order#
- Directory mode loads every
.dawnfile undersrc/(not just theuseclosure): modules that are never referenced must type-check too (bit-rot protection), and their test blocks are run bydawn testas well. - The
usedependency graph must not contain a cycle; the error prints the cycle (json/a → json/b → json/a). - Type checking and comptime evaluation proceed in topological order of dependencies; a
constreferenced across modules is already evaluated before the using side is. - Type identity: one
typedeclaration is one type across the whole program (each file is parsed/checked exactly once). - Entry: the entry module's
pub fn main() -> Unit !io. The entry module is the file a command names, or a project'ssrc/main.dawn;mainis reserved only in the entry module, and its shape (no parameters, returnsUnit,!io,pub, no labels in its row) is checked only there. Amainin any other module is an ordinary function, of any signature and any visibility.
10.6The bundled standard library and the prelude#
The standard library is Dawn source bundled with the compiler, organised as real
modules (`stdlib-naming.md`). The bundled modules are exactly these,
one for one with the compiler's own list std/modules.txt (scripts/doc-check.py holds the
two together; one module too many or too few fails it):
std/cursor: positions (cursors) in aStringstd/str: stringsstd/char: the character typeCharstd/fmt: rendering and parsing numbers (see below)std/loc: reading a call siteLoc, andhere()(§8.4)std/dtype: the device element formatDtype[T]andHasDtype; the integer modules and the three builtin formats declare their witnesses against itstd/narrow: the narrow binary floats bf16, binary16 and binary32, correctly rounded per operation (§11 "Math builtins")std/int/i8: the signed 8-bit integerI8, an opaque type overIntwith wrapping arithmetic (§11 "Fixed-width integers")std/int/i16: the signed 16-bit integerI16, an opaque type overIntwith wrapping arithmetic (§11 "Fixed-width integers")std/int/i32: the signed 32-bit integerI32, an opaque type overIntwith wrapping arithmetic (§11 "Fixed-width integers")std/int/u8: the unsigned 8-bit integerU8, an opaque type overIntwith wrapping arithmetic (§11 "Fixed-width integers")std/int/u16: the unsigned 16-bit integerU16, an opaque type overIntwith wrapping arithmetic (§11 "Fixed-width integers")std/int/u32: the unsigned 32-bit integerU32, an opaque type overIntwith wrapping arithmetic (§11 "Fixed-width integers")std/int/u64: the unsigned 64-bit integerU64, an opaque type overIntwith wrapping arithmetic (§11 "Fixed-width integers")std/float: the twoFloatvalues no literal spells,INFINITYandNANstd/list:Listfunctions and theIterinstancestd/bytes: byte strings, with UTF-8, hex and base64 encodingstd/io: console, files, environment and subprocesses (theFs/Proc/Enveffects and others)std/reactor: a synchronous line protocol whose state survives calls, the root a wasm reactor module keeps (§12.5)std/map:Mapstd/set:Setstd/hamt: internal module, the representation ofMap/Setstd/pvec: internal module, the representation ofListstd/mem: flat, mutable buffers of 64-bit integers,I64Buf, with their scope effectMemandwith_mem(design in `mem-buffer-design.md`). A buffer exists only insidewith_mem: a function that reads or writes one carries!Mem, and the checker refuses four kinds of escape by the identity ofstd/mem'sMem(the result type ofwith_memmay not contain a buffer, a function type whose row carriesMem, or any type variable; onlystd/memmay install a handler forMem; an effect operation's signature and a handler'svarmay not contain a buffer; a function value carryingMemmay not cross ause javacall)std/gpu: the host side of a GPU, theGpueffect and device buffers (§12.6)std/memfs: an in-memoryFshandler for tests; the rest of this specification does not discuss it, and its design is in `fs-real-path-design.md`
std/hamt and std/pvec are internal modules — the representations
of Map/Set/List (§11). They are bundled along with std and reference each other
inside std, but use std/hamt / use std/pvec outside std is a compile error: the
representation has to be replaceable wholesale, and being replaceable requires that no
program depends on it.
(std/fmt is where number rendering and parsing are implemented — fmt.dtoa is
to_string(Float) (§4.3); the implementations of the three parse_* (the EBNF in §11) are
not exported, the builtin spelling is the only way to write them, and
fmt.atoi/fmt.atod/fmt.atoi_radix are not names you can write. The module name exists
because the implementation is a piece of ordinary Dawn source rather than some backend's
host method, not because it is an API layer.)
use std/x resolves to a resource inside the compiler jar rather than to disk (the
src/std/ path on disk is reserved; putting a file there is an error); after that it
behaves exactly like an ordinary module import — qualified access map.insert(m, k, v),
selective import use std/list.{find} (§10.2/§10.3). The same short name can coexist
across modules (str.at / bytes.at), disambiguated by qualification or by selective
import.
The prelude is the high-traffic core of that, implicitly available without a use: the
constructors of List/Option/Result, println/print, map/filter/fold, the
sort family (std/list), parse_int (std/fmt), and the builtin
panic/todo/caller/bracket/catch_fault/catch_panic/
discard/dbg/expect/unwrap_or/to_float/to_int/to_string/get/range/
sort_by/join/parse_float/code_points/
from_code_points/char_is_letter/char_is_digit/char_is_alnum/char_is_upper/
char_is_lower/char_is_space/args/cast, all within one screen (for the full set see
the standard library reference, generated by
dawn doc --stdlib).
A top-level declaration may shadow a builtin/std function name (§10.3, Rust-style): the
resolution order is this module's declarations → std → builtins, and it is exactly this
rule that makes the std module's own pub fn len legal. The method names of prelude
traits get the same treatment: they enter the function namespace along with the prelude
(for which names, see the "built-in traits" section of the standard library
reference; the normative definition is in §3.5),
and may be shadowed by a declaration in this module — it is not an error at declaration
time. Only that spelling is shadowed, the trait itself is unchanged — impl Show[T] is
declared and found as usual, and ${...}, ==, for..in still find their impl by trait
(§10.3).
Adding to the prelude is compatible: adding a name to the prelude cannot make any program that already checks fail — the new name is either unused or shadowed by a declaration in that module. This is the precondition for the prelude being able to evolve (`prelude-namespace-design.md`).
The flat spellings from before modularisation (
map_insert,str_lenand so on) are no longer in the public namespace — only the prelude and module qualification are left; write an old spelling and the error tells you which module it moved to. The history is in `stdlib-naming.md`.
11.The standard library (semantics and criteria)#
This section is not a listing. Which functions there are, what the signatures look like, how to use each one — the full set is in the standard library reference, generated straight from the compiler by
dawn doc --stdliband therefore never out of step with the implementation. Copying it out by hand into the specification would only rot: what this section keeps is the half a listing cannot answer — what input is accepted, what happens at the boundaries, why the trade-off is this one.Where something is implemented is invisible to the user. These names come from two places: the compiler's builtin table, and the
std/modules bundled with the compiler (Dawn source, §10.6). Prelude names are implicitly visible; the rest are imported withuse std/xand called qualified asx.fn(...). Which side implements a name (builtin or std wrapper) affects neither its spelling nor its semantics (`docs/builtins-to-stdlib.md`).
The textual form of numbers. The language accepted by parse_int / parse_float
is this EBNF, enforced by std/fmt's own scanner (the two backends no
longer each delegate the grammar to a host parser; the host only does the decimal→binary
correct rounding after parse_float has validated, IEEE 754 round-to-nearest-even — on
that subset strtod and Double.parseDouble are the same function). Leading and trailing
whitespace is trimmed first according to Dawn's own whitespace table (the same
char_is_space table str.trim uses, not the host's):
int = [ "+" | "-" ] digit { digit } (* digit is ASCII 0-9 only *)
float = [ "+" | "-" ] mant [ exp ] | "Infinity" | "-Infinity" | "NaN"
mant = digit { digit } [ "." { digit } ] | "." digit { digit }
exp = ( "e" | "E" ) [ "+" | "-" ] digit { digit }
radix = [ "+" | "-" ] rdigit { rdigit }
parse_int(s, radix: r) (radix defaults to 10) uses the radix production: rdigit ∈
0-9 a-z A-Z (value = 10..35, upper and lower case have the same value), and a digit whose
value is ≥ radix is rejected; a radix outside 2..36 answers None. At radix 10 it is the
same language as int. Before 0.83.0 the radix version was a separate builtin,
parse_int_radix, now folded in. An integer outside the 64-bit range is None, not
wrap-around. Deliberately excluded (things today's host parsers do accept and Dawn
rejects across the board): underscores, the 0x prefix and hexadecimal floats (0x1p3),
the f/F/d/D suffixes, lower-case variants such as inf/nan, a signed NaN and
+Infinity (the legal special spellings are exactly the three to_string can emit, see
the round-trip closure in §4.3), and non-ASCII digits such as the full-width and
Arabic-Indic ones (the host's Character.digit accepts them; Dawn's digit set is closed
over ASCII).
The accepted language belongs to the function, not to how it is called: after
let f = parse_float, f(s) and parse_float(s) are the same function, and likewise for
parse_int -- except that as a value it loses its default (§3.1): let g = parse_int has
type fn(String, Int) -> Option[Int]. Up to v0.79.0 a call through a function value bypassed
this EBNF (f("1.5d") answered Some(1.5)), and taking parse_int as a value panicked at
compile time; that was an implementation defect, fixed as a bug rather than as a language
change (issue #283,
builtin-fn-value-lowering-design.md).
Case mapping. str.to_lower/str.to_upper are the Unicode simple (1:1) case
mappings: one code point in, one code point out, no locale, no context, so the code
point count does not change. That rules out the three kinds of special case in full
mapping — the ones that change length (ß → SS), the locale-dependent ones (Turkish
i) and the context-dependent ones (Greek final sigma). Taking the simple mapping is not
about saving effort: full mapping is not a function a backend can implement from one table,
and Dawn requires a primitive to be the same function on every backend. Cases that need
full mapping belong to a library that can take a locale.
That table belongs to the compiler (selfhost/src/embed/unicode_case.dawn, a generated file
that records the JDK which generated it), and the two backends each take a copy of their
own: the JVM one is written into dawn/rt/Strings, the native one into the emitted C. So
the answer str.to_upper gives does not move with the host JDK's Unicode version —
upgrading to a new Unicode is the one explicit act of regenerating this table, not a silent
change of answer because you compiled on a different machine. Classification (char_is_*)
works the same way; its table is selfhost/src/embed/unicode_class.dawn.
A character is a code point. code_points(s) -> List[Char] splits into characters (a
supplementary-plane surrogate pair merges into a single code point) and
from_code_points(cs: List[Char]) assembles from characters; len(s) is the code point
count, str.at returns a Char, and the one that returns List[String] is
str.chars. Indexing into a string is always by code point index, never by UTF-16 code
unit. char_is_letter/_digit/_alnum/_upper/_lower/_space take a Char, and
std/char's is_* are their public spellings.
Where the three criteria (§4.8) land on the string family: str.at(s, i) out of range
panics (criterion 1, i is a position the caller claims exists, the same as xs[i]
and bytes.at); str.strip_prefix/strip_suffix return Option (criterion 2, the test
and the stripping happen in one go, so the caller does not have to compute the offset
itself); str.slice/take/drop and bytes.slice clamp both ends, and from > to
gives the empty string (criterion 3, a range argument selects a stretch, it does not assert
that the endpoints exist). truncate is just take; there is no separate name for it.
Bytes and text encodings. The language promises exactly two charsets, and the
function name is the domain: bytes.decode_utf8_lossy and bytes.decode_latin1; there is no
charset registry. With no charset parameter there is no "unknown charset" failure mode, so
they return a bare String rather than an Option (the history is in
`stdlib-impl-notes.md`).
UTF-8 decoding comes in a lossy and a strict form, told apart by the function name in
the same way. decode_utf8_lossy replaces every illegal sequence with U+FFFD (the
replacement rule of the paragraph above); decode_utf8_checked rewrites nothing and
answers Err(Utf8Error { offset }) at the first illegal sequence, where offset is the
byte index that sequence begins at, which is also how many leading bytes of the input were
valid UTF-8. The two share one notion of what is illegal: the inputs decode_utf8_checked
accepts are exactly the inputs decode_utf8_lossy returns unchanged, and that is
normative. decode_latin1 has no strict form, because every byte is a code point and it
cannot fail. decode_utf8 is the old name of decode_utf8_lossy, kept under the
one-generation forwarder discipline of CONTRIBUTING §7 and removed in the next version.
hex and base64 are pure Dawn byte arithmetic (no use java, so both backends share one
definition), and the rules are normative: to_hex writes two digits per byte and lower
case is the canonical spelling, from_hex accepts either case and nothing else;
the encoding of to_base64(b, enc: Base64 = Standard) and from_base64(s, enc: Base64 = Standard)
is a value, Standard | StandardRaw | Url | UrlRaw (Go's four encoding/base64 values of the
same names): Standard* uses the standard alphabet of RFC 4648 section 4, Url* the
url/filename-safe alphabet of section 5, *Raw does not pad with = and the other two pad
to a multiple of four. The decoder accepts only the spellings to_base64(_, enc) can write: a
padded encoding requires its padding, a *Raw one refuses =, and each recognises only
its own alphabet (guessing the encoding would turn misspelled input into wrong bytes); the
spare low bits of the final group must be zero — otherwise one byte string would have
several spellings. The decoders in this family
all fall under criterion 2: text from outside is to be validated, not asserted.
Bytes and Buf are in §9.5.1; there are also the operator Bytes ++ Bytes and
content-wise ==/!=. Binary request bodies (multipart upload, WebDAV PUT),
crypto/signing and HTTP traffic all go through Bytes directly, no longer by way of a
latin-1 string.
Naming families. Length has exactly one name, len, and emptiness exactly one name,
is_empty — the same spelling in all five of str/list/map/set/bytes. The one
named exception is bytes.buf_at(b: Buf, i): bytes.at is already the byte lookup of a finished
Bytes, and the language has no overloading, so the write cursor had no choice but to take
another name (the criteria and the exceptions are under "naming families" in CONTRIBUTING).
A search that returns a position always returns an Option and never emits -1
(str.index_of and list.index_of are the same currency).
Sorting and extrema. The sort family requires the element/key type to have Ord
(§3.5); all of them are stable and a tie takes the first. list.any/list.all
short-circuit (the two faked with fold walk the whole thing); on an empty list they
are false / true respectively. list.unique asks for one thing more than the rest, a
Hash: deduplication with only Eq is quadratic, and Set's insertion order happens to
be exactly the order deduplication wants.
Result has no accompanying library functions (no map_err, no ok). match and ?
are enough; for crossing error types see the local helper in §8.1. Turning a Result into
an Option did not occur once in 40,000 lines of Dawn (not one of the 32 -> None arms
faces an Err), and throwing the error away runs against this language's grain.
Positions: Cursor. A function taking a code point index has to count from the start
of the string to that index on every call — O(n) per call, and O(n²) once it is in a loop.
A cursor is a position, not a count, so each step costs a constant. A position can only
be obtained from, passed back to and compared by the functions of std/cursor (== and
< <= > >= — the ordering of positions within one string is a legal operation on the
position type), and stored in a container/record for backtracking (it is an ordinary value,
and backtracking needs no extra machinery). Do not do arithmetic on it — arithmetic is
the only thing that can conjure up an illegal position in the middle of a surrogate pair.
Both the arithmetic and the conjuring are compile errors: Cursor is declared by
std/cursor as pub opaque type Cursor = Int (§2.7), and only that module may see it as
an Int. To write Cursor in type position you need use std/cursor.{Cursor} on top of
use std/cursor (the first binds a module alias, the second binds a name; two different
things). cursor.seek / cursor.offset are the bridge between the two position
currencies, one O(n) pass each, meant for the boundary (an Int index arriving from
outside, a position being reported to the outside); put them in a loop and you are back to
the O(n²) they exist to remove. A single call on a single string with the index version is
fine, but inside a loop the cursor version is mandatory — the measurements are in
docs/seq6-research.md §5's addendum.
The measure is the backend's, and never becomes an observable value. A position is an
offset into the string's representation, and the execution models do not agree on that
representation: UTF-16 code units on the JVM, UTF-8 bytes on native, and code points in the
comptime interpreter, which has no representation to offset into. So the number does not
leave std/cursor: it cannot be read (opaque), it cannot be folded into a constant
(§7.2), and it does not render. Show[Cursor] renders <cursor> rather than the number,
because otherwise a program's output would depend on who compiled it. To report a position,
report cursor.offset(s, c): the count in characters, which every backend agrees on.
Which measure is used is therefore an internal decision, changeable at any time, and not a
breaking change.
Using a cursor on another string is undefined. A cursor belongs to the string it came
from. An out-of-range position is clamped by char/next/prev and refused by slice,
but an in-range one from another string is indistinguishable from a position of this one,
so this specification promises no answer and the backends do in fact differ. Binding a
cursor to its string needs a tag that is itself backend-independent (otherwise the check
diverges exactly where the cursor did), which costs a walk to compute and a second word per
position. That was weighed and declined on 2026-08-16 in favour of writing the boundary
down; every other channel is held shut by scripts/spike-native/cursor_currency.dawn,
which compares this module's public answers across both backends. Comptime evaluation is
outside that trade-off: the interpreter's positions already know which string they were
taken on (inside the interpreter only; nothing of it reaches an artifact or costs a word at
run time), so a constant fold that uses a cursor on another string, or compares or tests
for equality two cursors from different strings, is refused with an error rather than
given a third answer.
Container representations. Map/Set are represented by the pure-Dawn std/hamt (a
persistent HAMT) and List by std/pvec (a persistent vector); these are internal
modules: use std/hamt / use std/pvec outside std is a compile error, and the
diagnostic points back at std/map/std/set/std/list (§10.6). The representation has to
be replaceable, and being replaceable requires that nobody depends on it — which is why the
standard library reference does not list these
two modules either. The semantics of the containers (persistent interface, keys must be
Eq + Hash, iteration in insertion order, equality independent of order) are in §2.2.
Where IO stands. std/io's console, environment and subprocess functions are !io; the
file functions ride the named effect Fs (§6.5), whose production handler is io.with_fs_real
and which a test answers from a table. Anything that can fail returns
Result[T, ForeignError] (§9.8.1) — a structured payload rather than the sentence a
barrier has already rendered; the reasoning is in
`audit/error-model-design.md`. The further normative
behaviours are:
io.write_file(path, content)creates missing parent directories automatically. ItsOkcarries no value- The entry names of
io.list_dir(path)are sorted in code point order. The sorting is done in the std layer (list.sortgoes through the language's ownOrd[String]), and theio_list_namesprimitive promises no order — the backends no longer each sort their own way. When path is not a directory it is anErrwhosekindis"io.not_a_directory"— that one,io.run's"io.no_program",io.delete's"io.invalid_delete_path", andio.real_path's"io.relative_path"are the four kinds std mints itself; all the others come from the backend io.delete(path)returnsResult[DeleteOutcome, ForeignError]: deleting a file or empty directory isOk(Deleted), an absent path isOk(NotFound), and every other host refusal (including a non-empty directory) is anErr. It never deletes recursively. The std layer rejects the empty string and paths ending in/with kind"io.invalid_delete_path"instead of letting a backend normalize them. A path containing U+0000 is also anErrand must never name the prefix before the NULio.exists(path),io.is_dir(path), andio.is_symlink(path)are Bool queries: an absent path, a type mismatch, or an invalid host path returnsfalse. In particular, a path containing U+0000 must neither fault nor query the prefix before the NUL; all three returnfalsedirectlyio.real_path(path)returns the path with every symbolic link followed and.and..resolved by the host (realpath(3)semantics): a..after a link goes to the parent of the link's target, not of the directory the link sits in.pathmust be absolute and must exist. A relative path is rejected in the std layer with kind"io.relative_path"and never reaches a backend — resolving it would read the working directory, which isEnv's answer and notFs's; a path that does not exist, or that contains U+0000, is the backend'sErr. wasm32-wasi has no absolute paths to resolve, so there it is always anErrio.getenv(name)returnsNonewhen the variable is unset or when the name cannot be represented by the host environment API. In particular, a name containing U+0000 must neither fault nor query the prefix before the NUL; it returnsNonedirectlyio.read_line()breaks lines byBufferedReader.readLine's rules, the same on both backends:\n,\rand\r\nall end a line, and the terminator is not part of the result; a last line with no terminator is still a line, and end of input isNone. A\rhands the line over at once without reading ahead (a terminal that has sent\ris waiting for an answer, and a read-ahead would hang), and a\nright after it is dropped by the next callio.read_stdin(n)returns exactlynbytes, shorter only at end of input.nis the most the caller will take, not a size to allocate up front: a hugenon a short input returns the bytes there are, andn <= 0returns emptyBytes. On the JVM one call returns at most2^31 - 1bytes (the most a byte array holds)- A closed standard output (
headexiting first inprog | head -1) does not end the program: both backends swallow the failed write, the program runs to its end, and its exit code is unaffected. On the JVM this isPrintStream's behaviour (the exception only goes intocheckError); the native runtime handles SIGPIPE, and a failed write only sets stdio's error flag. Child processes the program starts get the default SIGPIPE disposition io.stdin_ready(timeout_ms)answers exactly one thing: whether at least one byte is readable right now. End of input does not count as ready — a write end that is already closed and "connected but silent" give the samefalse, and the difference between them is reported byio.read_stdin, which is still the only reader. So a loop driven by it alone will spin once input ends: when there is nothing to do you must go and make that blocking read (the read loop inselfhost/src/lsp/server.dawnhas this shape).timeout_msis an upper bound, not a lower one: returningfalseearly is always legal (a regular file at end of file returns immediately), because the only thing a caller holding afalsecan do is stop waiting;trueis the one that must not be guessed. This shape was chosen because it is the only one both backends can implement without starting a thread: the "a read will not block" reading would have to answertrueat end of input, and the JVM cannot tell that state apart from silence (available()is 0 in both cases), while starting a background reader thread would eat the bytes on behalf of a child process that inherited stdin (measurements in `docs/audit/lsp-robustness-design.md` §2.2.1)When the bytes read in are not UTF-8: nothing guarantees that the bytes the operating system hands over are UTF-8, so every read primitive that turns bytes into a
Stringmust take a position. There are two classes, and the two backends agree:io.read_filefails (Err, i.e. a fault a barrier can catch). Reading text and getting back something other than the text on disk is exactly what this rule is there to stop; when the input was never text in the first place, useio.read_file's byte twinio.read_bytes, which does not look at a single byteio.read_line/io.cwd/io.getenv/io.list_dir/io.temp_dir/argsreplace: an illegal sequence becomes U+FFFD, followingbytes.decode_utf8_lossyto the letter (one U+FFFD per illegal sequence, not one per byte). A file name that is not UTF-8 is still a file name, and rejecting it would leave the program unable even to list a directory- From which follows an invariant: there is no ill-formed UTF-8 inside a
String. That is not rhetoric — it stops overlong encodings from flowing downstream and being taken for real characters by primitives that walk by code point (`stdlib-impl-notes.md`)
Math builtins. There is no general math-function family and no host-math wrapper. The
builtin numeric surface is limited to Int/Float arithmetic operators; the builtin
conversion functions are
to_float and to_int. Additional
operations live in pure Dawn source libraries; for example, std/narrow computes square
roots with integer arithmetic and does not depend on a host math library.
std/narrow's three formats have + - * / % and unary - (§3.5's Add to Neg, with the
default rounding, nearest even; % is the exact truncating remainder) and FromFloat:
let w: BF16 = 0.1 is bf16(0.1), folded at compile time. They take no integer literals.
Fixed-width integers. The compiler owns two numbers, Int and Float
(literal-system-design.md D1); I8, I16, I32, U8, U16,
U32 and U64 are opaque types over Int (§2.7), one std module each (std/int/i8 ...
std/int/u64; use std/int/u32 binds the last segment, u32), made first-class numbers by §3.5's arithmetic and literal traits. The criteria:
- Invariant and representation: the value is the
Intin range (I8is -128..127,U32is 0..2^32-1, and so on).U64has no invariant: everyIntis the bit pattern of someU64, and a value of 2^63 or more has a negativeInt. - Arithmetic wraps, by
Int's own rule (§4.3):+ - *and unary-keep the low w bits of the result (read as two's complement for the signed types);/and%truncate and panic on a zero divisor withInt's message; a signedMIN / -1wraps toMIN;U64's/ % <and rendering are unsigned. Overflow is not checked: two overflow rules in one family of types would have every reader looking up which applies. The checked forms arechecked_add/checked_sub/checked_mul, answering anOption. - Literals go through
FromInt, whose pure impl folds at compile time, solet b: U8 = 300is a compile error with the range stated by the library (§1.5). A float literal never becomes an integer type;U64takes no negative literal, and its large values are writtenu64.wrap(-1)oru64.MAX. In the declaring module a literal is typed at the target as always. - No mixed widths:
U8 + U16is a compile error (the two sides differ); there is no implicit widening or narrowing. Conversions go throughInt:x.of(n)answers anOption(Noneout of range),x.wrap(n)keeps the low bits,x.to_int(v)gives theIntback (the bit pattern, forU64). - Equality, hashing and order are inherited from the target
Int(§2.7), which is right for in-range values; onlyU64writes anOrdof its own, without which a value above 2^63 would compare below zero. Rendering is decimal (impl Show, unsigned forU64). - Bitwise operations are named functions:
and,or,xor,complement(notis a keyword),shl,shr,rotl,rotr. A signedshrcopies the sign bit in, an unsigned one zeros; a shift or rotation count is taken modulo w (asInt's shifts take the low six bits). Operator forms wait for bitwise traits of their own.
Implementation strategy: wrap Java thinly wherever a thin wrapper will do (String simply
is java.lang.String), while the persistent List/Map/Set are pure Dawn source
throughout (List = the std/pvec persistent vector, Map/Set = the std/hamt
persistent HAMT, all with deterministic insertion order), leaving the backends a single
primitive to implement: Array.
12.The compilation model#
12.1Outputs#
There are two backends, each with its own driver. dawn is the JVM toolchain (it
emits bytecode); dawnc is the C backend (it emits C11 source and then hands it to
cc). They are not the same road: dawn build --native is still the JVM backend, it
just hands the jar from the previous step to GraalVM native-image (§12.3); dawnc
produces no bytecode at all.
The JVM toolchain dawn (wherever the rest of this chapter does not name a driver,
it means this one):
| Command | Output |
|---|---|
dawn check <file or dir>... | Type checking only. Prints ok and exits 0 when clean; renders the diagnostics and exits 1 if there is any; exits 2 on a usage mistake |
dawn run [compiler-options] <file.dawn or dir> [-- <program-args>...] | Compiles into memory / a temporary directory, starts a JVM and runs it |
dawn build <file or dir> -o app.jar | An executable jar (Main-Class: main is already set) |
dawn build ... --native -o app | The previous step + GraalVM native-image, a standalone binary (§12.3) |
dawn test <file or dir> | Compiles the variant that includes the test blocks and runs it (directory mode aggregates the tests of every module) |
dawn fmt <file or dir>... | Formatting (directory mode recurses over every .dawn; a directly named file must end in .dawn, or it exits 2) |
dawn __emitc <file or dir> -o out.c | A C translation unit. A hidden subcommand: this is the C backend's entry point on the JVM toolchain, and dawnc's selfhost and differential comparison both go through it |
The C backend driver dawnc (a single-file static executable, shipped with each
release; it needs neither a JVM nor this repository):
| Command | Output |
|---|---|
dawnc check <target>... | Type checking only, aggregates diagnostics from every target |
dawnc emitc <target> [-o out.c] | A C translation unit (compiled together with the runtime in runtime/c/) |
dawnc build <target> [-o out] | The previous step + a call to cc ($CC overrides it), a standalone executable |
dawnc build --target wasm [--reactor] <target> [-o out] | The same C handed to clang, a WebAssembly module (§12.5) |
dawnc run [--std <dir>] <target> [-- <program-args>...] | The same, and runs it as soon as it is compiled |
dawnc test <target> | Compiles the variant that includes the test blocks and runs it |
dawnc fmt / doc / add / lsp | Output is byte-for-byte identical to dawn's subcommands of the same name (scripts/native-cli-diff.sh pins these four to the JVM's bytes) |
dawnc version | The version number (it reports (native) itself, so it is not literally identical to dawn --version) |
The two lsp subcommands share one stdio-framing implementation. A header, from its
first byte through CRLF CRLF, is at most 8192 bytes: a terminator completed exactly on
byte 8192 is valid, while an incomplete header that reaches the limit is rejected
immediately. A body is at most 67108864 bytes. Every nonempty header line must have the
shape 1*tchar ":" field-value; tchar is an
ASCII letter or digit, any of ! # $ % & ' * + - . ^ _ | ~, or a backtick. A missing
colon, an empty field name, or a space/parenthesis in the field name is therefore a framing
failure, while a syntactically valid unknown header is ignored. The field name
Content-Length is compared ASCII-case-insensitively. Its value is one or more ASCII
decimal digits, optionally padded
at either end by SP/HTAB only. Empty values, signs, fractions, exponents, underscores,
Unicode digits, Int overflow and values above the body limit are invalid; 0 is valid.
Duplicate fields are valid only when every independently parsed numeric value is equal, so
leading zeroes do not conflict; an invalid occurrence or a numeric conflict rejects the
frame.
The body must be strict UTF-8. If a complete bounded frame has invalid UTF-8 or an invalid
JSON body, the server replies with -32700, id: null, and continues with the next frame;
a replacement decoder must not repair invalid wire bytes into valid JSON. Every other
framing failure (a malformed or partial header, a partial body, a missing, invalid,
conflicting or oversized length, or an oversized header) produces exactly one copy of the
parse error below and closes the read loop; bytes after the failure are never interpreted
as another frame. Clean EOF with zero header bytes is silent. Header syntax and all length
validation are checked before the underlying stdin-read primitive is called.
{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}}
The two drivers parse argv independently, but target cardinality is one shared contract (TOOL-04):
| Subcommand | Target / selector cardinality |
|---|---|
check | 1..N targets |
test | Exactly one target, or --stdlib; the two are exclusive |
doc | Exactly one of one target, --stdlib, or --builtins |
build | Exactly one target |
emitc | Exactly one target (the JVM entry point is the hidden command __emitc) |
fmt | 1..N targets |
A missing target, too many targets, or conflicting selectors is a usage error: it exits 2
before loading a target or producing a backend output, and both drivers use identical
diagnostic bytes for the same error. N has no artificial upper bound for check or fmt;
testing with two targets demonstrates that they really are batch commands, not that their
maximum is two.
run uses two explicit argv namespaces (TOOL-03):
dawn run [compiler-options] <target> [-- <program-args>...]
Compiler options are parsed only before the target. The separator may be omitted when the
program has no arguments; both run target and run target -- pass an empty argv. If any
token follows the target, the first one must be --; otherwise stdout is empty, stderr is
exactly the following line, and the command exits 2:
error: usage: dawn run [compiler-options] <target> [-- <program-args>...]
The separator itself is not forwarded. Every token after it reaches the program's args()
verbatim and without further interpretation, including an empty string, --,
--closure, and -o. args() is whole-program input: whether the call is in the entry
module or any dependency module, it returns the same list supplied when that program was
started. The JVM and native drivers keep independent parsers and are held
to one absolute stdout/stderr/exit contract; see run-argv-boundary-design.md for the full
rationale.
The few subcommands dawnc lacks are not holes, they are the backend's boundary: it
refuses use java (Java interop is a JVM backend capability, §9), and build-to-jar,
lock and cache likewise only mean something on the JVM side. Both drivers accept
--std <dir> to swap the standard library source; without it they take DAWN_STD (when
not empty), and otherwise the copy compiled into the toolchain, never a std/ under the
working directory (std-dir-loading-design.md).
The argument may be a single file or a project directory (§10.1): directory mode
loads every module under src/, with src/main.dawn as the entry point; single-file
mode walks upwards for a src ancestor and takes it as the root. The jar collects every
module class, and Main-Class = the entry module's class main.
Third-party jars: --cp <jars> (before run's target, and for test/build; separated by the path
separator, repeatable). Compile-time use java resolution and run-time loading share
this one classpath. build records each jar in the manifest's Class-Path (relative to
the output directory, so moving the jars means moving them together), and the output
still runs directly with java -jar; build --native instead passes them to
native-image in -cp form (whether a third-party library's reflection/JNI survives
native-image is the library's responsibility, see §12.3).
--cp itself does no dependency resolution: it mounts exactly what you give it, and you
have to list the transitive dependencies yourself. A library that needs a dependency
tree goes through [java-deps] (the dawn.toml of §10.1) — that path resolves Maven
transitive dependencies via coursier. This section once said "Dawn has no dependency
resolution — it only accepts single-jar, zero-transitive-dependency libraries", which was
the fact before [java-deps] existed and directly contradicts §10.1.
Guaranteed: the same program behaves identically under all three outputs — the
bytecode running on a JVM, the native-image binary from --native, and the executable
from the C backend (apart from startup time and memory footprint). A program with
use java only has the first two outputs; that is a boundary it declared itself, not an
inconsistency.
12.2The bytecode mapping#
| Dawn construct | JVM implementation |
|---|---|
Module json/lexer | One class, internal name json/lexer (package json, class lexer), functions become static methods |
| ADT/record | Class names carry the module prefix: json/lexer$Token, constructor json/lexer$Token$Num |
| Cross-module call | invokestatic on the other module's class; constructors/fields as usual (the classes are public) |
| ADT | abstract class + final subclasses; a payload-free constructor is a singleton |
| record | final class + fields (does not rely on Java records, so older bytecode targets still work) |
match | An instanceof chain + field reads (no indy, no pattern switch) |
| lambda/closure | Each closure is an ordinary generated class implementing FnN, with captures in final fields; SAM conversion generates a separate adapter class holding that FnN |
| generics | erasure + boxing |
| structurally equal types | ADTs/records/tuples still get matching equals and hashCode, but those are for Java callers — Dawn's ==/hash are Core functions that lowering expands structurally (§4.3), and Map/Set reach them through dictionaries. When an impl Eq/impl Hash exists, these two methods forward to that impl |
Int/Float/Bool | native long/double/boolean, boxed only in generic positions |
Unit | Ldawn/rt/Unit; — a singleton reference; it takes one slot and is no different from any other reference in parameter/field/capture positions |
Never | Static Dawn calls use return descriptor V. Calls through erased FnN.apply return Object; the caller discards it with POP before terminating. When a SAM adapter calls its bridge, it follows the SAM return descriptor: void produces no stack value, a one-slot result is discarded with POP, and a two-slot result with POP2; the adapter then emits aconst_null; athrow. Never has no parameter, field, or other storage representation |
panic | Throws dawn.rt.PanicError (a subclass of Error, so catch_fault does not catch it; only the isolation point catch_panic does, see §9.8) |
The runtime support classes (dawn/rt/Lists, Strings, Io, Show, Maps, Tuple*,
Fn*, and so on) are generated once per program and shared by all module classes.
12.3Ahead-of-time compilation: the native-image contract and the C backend#
Both roads get you an executable that does not depend on a JVM, but which layer of the compilation stack they fork at determines the constraints each one carries.
dawn build --native (GraalVM native-image): goes through the whole JVM backend and
takes the jar for closed-world analysis. The language constructs are guaranteed not
to produce reflective calls, custom indy bootstraps, dynamic class loading or JNI (Java
interop uses ordinary invokes). A --native build therefore needs no reachability
configuration. If a Java library you pull in uses reflection itself, that is the
library's responsibility — the error message will point out that this is beyond what
Dawn guarantees.
dawnc (the C backend): forks after Core, never passes through bytecode, so the
contract above means nothing to it — there are no classes to analyse and no native-image
involved. The price is that it has no Java: use java is refused outright (§12.1).
The output is an ordinary executable compiled by cc, and the runtime lives in
runtime/c/.
12.4Tail calls#
Self-recursive tail calls are guaranteed to compile to a loop (the stack does not grow) — for top-level functions and for local named functions (§3.1) alike. Mutually recursive tail calls are not guaranteed. The rule: a call to the function itself inside its body sits in tail position (return position, the tail position of a match/if branch, the last expression of a block).
12.5Build targets#
There are three build targets. Choosing a target means choosing a driver and a command;
jvm and native are this section's names for them, not spellings you can write.
| Target | Command | Output |
|---|---|---|
jvm | dawn build <target> -o app.jar (with --native, then handed to GraalVM native-image, §12.3) | An executable jar |
native | dawnc build <target> [-o out] | An executable compiled by cc |
wasm | dawnc build --target wasm [--reactor] <target> [-o out] | A WebAssembly module (WASI preview 1) |
Only dawnc build accepts --target, and its only value is wasm; any other value is a
usage error (exit 2). dawn build has no --target, and writing one exits 2 as well.
--reactor must come with --target wasm; otherwise the build exits 2.
wasm and native are one backend: the same emitted C and the same runtime
runtime/c/dawn_rt.c, which swaps out what the target does not have under __wasi__
conditional compilation. The compiler is clang: DAWN_WASM_CC overrides it, the default is
clang, and CC is not read. The triple is whichever one the compiler in hand has a
sysroot for, wasm32-wasip1 first and wasm32-wasi otherwise. Without -o, the output is
named after native's default with .wasm appended. use java is refused, as on native
(§12.1).
A command module (no --reactor) exports _start and memory. The host calls
_start once and the program ends when main does, with the same meaning as a native
process.
Reactor mode (--reactor) links without a _start, and the module's exports are
exactly three: memory, _initialize and dawn_turn.
- The host must call
_initialize(wasi-libc's constructors) once, after instantiation and before the firstdawn_turn. dawn_turntakes no arguments and answers ani32. One call is one turn: it calls the program'smainwith an empty argv (args()is[]), flushes the buffers of standard output and standard error oncemainreturns, and answers 0.- Every turn enters
mainfrom the top, and no Dawn stack survives a turn boundary. The only thing that lives across turns within one instance is the rootstd/reactor'sserveinstalled; beyond that, this specification promises no value survives from one turn to the next.serve's contract (one state type per instance; a failing or panickingstepkeeps the previous state) is in its documentation. - A reactor has no io handler of its own: the mode adds no effect, handler or io
primitive. The program's
!iois still std's:io.read_linereads WASI fd 0,printlnwrites fd 1, and standard error is fd 2; the host supplies and collects each turn's bytes, and the end of the input the host supplied is end of input. The module imports onlywasi_snapshot_preview1functions; which ones is decided by the linked wasi-libc from what the program uses, and is not part of this specification. What an import the host does not implement answers is up to the host. - What a bridge sees is exactly the above: three exports, and bytes on stdio. The message
format, one JSON line in and one JSON line out, is
packages/tea-dom's contract (tea_dom/wire), not the language's; the design and its reasons are in `dom-bridge-design.md` (in Chinese). - When the program exits through
Exit, or a panic goes uncaught, the guest callsproc_exitand the instance cannot be used again. To keep an instance answering after an application panic, catch it inside the turn withcatch_panic, which is whatservedoes.
The standard library on wasm (the same in both modes): every bundled std module compiles for wasm, and every difference is in the runtime. Each row below is an explicit refusal by the runtime, not an emulation; outside the table, behaviour matches the native output (§12.1's guarantee extends to wasm).
| Capability | On wasm |
|---|---|
File io (Fs) | Through WASI preopens; which directories are visible is the host's decision |
io.run (Proc) | Err (a fault, io_run: no processes on wasi) |
io.real_path | Err (a fault): wasm32-wasi has no absolute paths to resolve (§11) |
io.temp_dir / io.temp_file | Err (a fault) |
io.copy_permissions | Succeeds whenever src exists, but changes nothing: the target has no file modes |
One-shot continuations (control arms, discard, §6.5) | Panic: dawn: one-shot resumption is not available on wasm |
std/gpu's with_gpu_real | Every operation that reaches the device answers gpu.unsupported_backend (§12.6) |
| Threads, a large stack | None; the recursion depth limit depends on the engine's call stack and is undefined |
Checked by machine: scripts/wasm-contract/run.sh builds one corpus both natively and as
wasm (command modules), runs the wasm under node's WASI, and requires stdout to equal the
native run byte for byte, the runtime's ledger of object births and deaths to balance to
zero, and an uncaught panic to exit 1 on both targets; scripts/wasm-dom-contract/run.sh
runs the reactor and the DOM bridge against a recording document stub and compares the
transcripts byte for byte. Both run in CI's wasm-target job.
12.6The device backend: GPUs#
The device backend is not a third backend of the compiler. Neither driver knows what a kernel, Tile IR or a GPU is, and neither calls any device toolchain. It is two layers of library:
- The host layer,
std/gpu(bundled with std): theGpueffect,DeviceBuffer[D]and the two handlers; the element formats are instd/dtype(Dtype[T]) and in each value type's module. - The device layer,
packages/tileir(a source package brought in through[deps], not part of std): theDeveffect, the recording handler,TileProg, the Tile IR text renderer and the bytecode writer. The host reference implementations of kernels are inpackages/tileref.
The reasons, the roadmap and the measurements are in `tile-backend-design.md` §4–§6 (in Chinese).
A kernel body is an ordinary Dawn function whose effect row is Dev alone, of the shape
fn(a: Param[Float], b: Param[Float], out: Param[Float]) -> Unit !Dev.
- It does not execute on the device, and it computes no number on the host. Every
Devoperation produces or consumes handles: opaque types such asTile[D](of any rank; a scalar is rank 0),IdxandParam[D], whose phantom parameterDis a format's value type (§2.7). Handles are issued by the handler; using one kind of handle as another, or a hostIntas a handle, is a type error. - Recording:
tileir/prog'strace_kernel(name, params, body)runsbodyexactly once, at host run time, under the recording handler, and answers aTileProg(an ADT in SSA form).paramsis the format name of each entry parameter, by position. A body that issues the same operations in the same order records equal programs. - Ordinary Dawn control flow in the body (
if,for, recursion) is evaluated by the host while recording: a branch that depends only on what the host knows is unrolled, and a hostInt/Floatbecomes a constant when it enters the record. Control flow that depends on a tile value must go throughtileir/dev's structured functions (d_range,d_loop,d_if,d_reduceand so on), which become regions in the record. - Memory operations are ordered only by the token chain the recording handler keeps; program order gives them no order at all.
- The following panic while recording (they are not an
Err):param(d, pos)disagreeing withparams(a position out of range or a different format); a tile dimension that is not a power of two; loops nested deeper thanMAX_LOOP_DEPTH(16); one recording issuing more thanMAX_HANDLES(65536) handles; a region left open, or closed with the wrong number of values. - Outputs:
tileir/render'srender(prog)gives text in thecuda_tiledialect, andtileir/bytecode'sencode(prog)givescuda-tilebytecode whose version is pinned inBYTECODE_MAJOR/BYTECODE_MINOR. OneTileProgis one module holding oneentry @<name>, wherenameistrace_kernel's first argument; every entry parameter is either a pointer to its format (ByPtr, a buffer) or a rank-0 value (ByValue, a scalar passed by value). Turning the bytecode into a device image (a cubin) is done by NVIDIA'stileiras, outside the Dawn toolchain; this specification does not say who calls it or when.
The Gpu effect has seven operations (gpu_alloc, gpu_copy_from_host, gpu_copy_to_host,
gpu_launch, gpu_module_global, gpu_free, gpu_sync), monomorphic and handle-level; a
program uses the typed functions over them: alloc, copy_from_host, copy_to_host, free, launch,
module_global and sync.
- Every operation answers
Result[_, ForeignError]. Above the operations std mints three refusals of its own, byte-identical under every handler:gpu.bad_length(alloc's length below 1),gpu.length_mismatch(copy_from_host's data length is notlen(t)) andgpu.bad_grid(an axis of the grid below 1). All three are minted before any handler is asked. Every other answer is the raw outcome of the device the handler stands for: the seam sits below the error surface. launchnames a kernel by a string:launch(kernel, grid, args, gy: Int = 1, gz: Int = 1);grid,gyandgzare the three axes of the grid, counting tile blocks;argsis a list ofLaunchArg, in the order of the entry parameters:erase(buffer_arg(t))is a buffer anderase(scalar_arg(v))a scalar passed by value (i32,i64,f32orf64). The binding from names to kernels is the table the handler is installed with, and a name not in it answersgpu.no_kernelunder both handlers. The language checks neither that this name is the onetrace_kernelwas given nor thatargsagree with the entry signature in number and format: that is the program's responsibility.launchreturning is not the kernel having run. Only acopy_to_hostaftersyncis guaranteed to see the writes of the launches before it.module_global(d, kernel, name)answers aDeviceBuffer[D]over one exported global of the module that defines the kernel, sharing the module's storage; two lookups of one symbol answer the same handle, andfreeon it is accepted and does nothing.
The two handlers:
pub fn with_gpu_fake[T](kernels: Map[String, (Int, WideRefFn)], body: fn() -> T !Gpu,
globals: Map[String, List[(String, String, List[Float])]] = map.empty()) -> T
pub fn with_gpu_real[T, !e](kernels: Map[String, Bytes], body: fn() -> T !Gpu !e) -> T !io
- The fake device
with_gpu_fakeis pure: it works on every backend and in every test block, with no device, no driver and no!io, and two installations share nothing. It cannot be used at comptime: its table is aMap(§7.2) and its handler has state cells (var), and the comptime interpreter can represent neither. - The fake device never runs a kernel body. Its table maps a name to (a number of buffers,
a host reference implementation).
launchchecks the arguments first: none at all answersgpu.no_arguments, a count different from the entry's answersgpu.bad_arity, and a handle never issued or already freed answersgpu.no_such_buffer; then it hands the reference the format name and the contents of every argument buffer, in order. The reference answers[(argument position, contents)], and each pair is rounded to the format of that buffer (round_to) and written back; a position out of range answersgpu.bad_write_backand writes nothing. Every input is read before any write-back. The grid is not read. - A buffer holds bytes, as memory does:
copy_from_hoststores them as given andcopy_to_hostanswers them unchanged, so an upload followed by a download is the identity for every format and every value. A reference implementation seesFloats, so at a launch the fake device decodes each buffer toFloats, and ani64buffer holding a value aFloatcannot hold exactly (beyond ±2^53) is refused withgpu.fake_inexact, naming the buffer and the element, rather than rounded. A write-back is rounded to the buffer's format. globalsmaps a kernel name to the globals its module exports (symbol name, format, the contents the declaration gives them);module_globalanswers from it, and a name not in it answersgpu.no_module_symbol. Left out, no module exports a global. It comes afterbody(a defaulted parameter comes after the ones without a default), so a trailing block lands onglobalsandbodyis written in the parentheses.- The real device
with_gpu_realis implemented on the native target only. The runtimedlopenslibcuda.so.1at the first operation that needs the device and opens one context on device 0; whenbodyreturns it releases the context and the library, and with them every buffer the program did notfree. A kernel's cubin is loaded the first time it is launched (or queried bymodule_global) and reused for the rest of the installation. - A driver's refusal is passed on unchanged, with kind
cuda.<CUresult name>; no libcuda to load answersgpu.no_driver; a driver that cannot load the ELF ABI the cubin uses answersgpu.driver_too_old, and the cubin is not handed to the loader. - The JVM and wasm have no device runtime: every operation that reaches the device answers
gpu.unsupported_backend, and the handler passes it on unchanged. The real handler does not check that the arguments agree with the kernel's entry in number and format.
The contract between the two handlers:
- The same
!Gpufunction runs under both handlers without a word changed. - A refusal that does not reach the device has the same kind under both handlers:
gpu.unsupported_dtype,gpu.no_such_buffer,gpu.no_kernel,gpu.no_arguments. On the real side these answers are the same on every backend, with or without a driver. - For a kernel K and the reference implementation R registered under its name in the fake
device's table: the values the fake device answers must equal what the real device
answers running K. An exact-tier kernel is equal bit for bit over the whole buffer (telling
-0.0from0.0); a tolerance-tier kernel satisfies|got - want| <= atol + rtol * |want|element by element, withatol = rtol = 1e-5. The tier is a property of the kernel, declared by the layer-2 diff program; the criteria are in `tile-backend-design.md` §6.6. The obligation falls on whoever writes the pair K, R: neither the compiler nor std checks it, and layer 2 below does.
Types that cross the boundary: only buffers cross between host and device.
DeviceBuffer[D]is an opaque type whose representation is (handle, element count);Dis a format's value type, soDeviceBuffer[Float]andDeviceBuffer[BF16]are two types and passing the wrong format is a type error rather than a launch failure. A handle is an opaque integer issued by the handler, and a program never invents one.- There are 16 formats, each a value type plus a witness constant of the same name, a
Dtype[T](use std/narrow.{BF16}brings in the type and the witness;alloc(BF16, n),DeviceBuffer[BF16]), and only they implementHasDtype:Float(witnessF64),Int(I64) andBool(I1, a tile format only) instd/dtype;BF16,F16,F32,TF32,F8E4M3FN,F8E5M2,F8E8M0FNU,F4E2M1FNandF8E5M3FNUinstd/narrow(the last six are storage-only, with no arithmetic);I8,I16,I32andU8instd/int/*;I4instd/gpu.U16,U32andU64have none (the dialect has no unsigned integer tile types), so aDeviceBuffer[U32]is a type error atalloc. - In type position write the value type; in value position write the format's name. For
std/narrowand the fixed-width integers the two are spelled alike (Tile[BF16],alloc(BF16, n)); for the three builtins they areFloat/F64,Int/I64,Bool/I1. - The formats a buffer can be allocated in are exactly the 12
element_bytesknows:f64,i64,i32,tf32,bf16,f16,i16,i8,u8,f8E4M3FN,f8E5M2,f8E8M0FNU. Both handlers answergpu.unsupported_dtypefor the rest,F32included: it can be written in a type but not allocated.I4,F4E2M1FNandF8E5M3FNUare tile formats only, and no buffer of any of them exists. - Values go in and out as
List[T], whereTis the buffer's element type:copy_from_host(b: DeviceBuffer[T], xs: List[T])andcopy_to_host(b: DeviceBuffer[T]) -> Result[List[T], _]needT: HasDtype + DeviceBits.copy_from_hostwrites each element as the format's bit pattern, little-endian, and nothing is rounded on the way in (the value is already the format's: aBF16is a bfloat16, and anIntcrosses whole);copy_to_hostanswers exactly what the buffer holds. That makes every format lossless, ani64beyond ±2^53 and a NaN's payload included.DeviceBitsis implemented by the formats a buffer can hold;Bool,I4,F4E2M1FNandF8E5M3FNUhave a format and no buffer, so uploading aDeviceBuffer[Bool]is a type error. On the effect, the data crosses asBytes(gpu_copy_from_host(handle, data: Bytes),gpu_copy_to_host(handle) -> Result[Bytes, _]), elementiin bytes[i * w, (i + 1) * w)for the format's widthw, little-endian.encode_floats(dtype, xs: List[Float])anddecode_floats(dtype, bytes)are the same encoding by format name overFloats, for a program whose buffers have no common element type; ani64beyond ±2^53 does not survive them. - Scalars cross by value: a launch's arguments are buffer handles or scalars passed by value
(
i32,i64,f32,f64; the argument-area layout of smaller formats has not been measured, so they are not accepted). A host number reaches a kernel as a constant written into the program at recording time (one program per value), or as aByValueparameter passed at run time (one cubin).
Checked by machine (where, and what):
| Layer | What is checked | Where | When |
|---|---|---|---|
| 0 | Every kernel is recorded twice and the records are equal; text goldens (*.mlir) and bytecode goldens (*.tilebc), byte for byte on both backends | scripts/tile-golden/run.sh | CI tile.yml |
| 1 | The pinned tileiras --gpu-name sm_86 accepts every bytecode golden: exit 0, no error: in its output, and GLOBAL FUNC <kernel> in the cubin's symbol table | the assemble step of scripts/tile-golden/run.sh | CI tile.yml |
| 2 | One set of !Gpu programs runs under both handlers and agrees bit for bit or within tolerance, by the kernel's tier; on the JVM the real handler answers gpu.unsupported_backend at the first operation | scripts/tile-gpu-diff/run.sh, which appends its result to a ledger | By hand, on a machine with a driver |
- Layer 2's ledger
scripts/tile-gpu-diff/ledger.txt(sm_86) is read by CI'srun.sh --check: the last line parses, itsinputs=digest equals the digest of the tile paths at HEAD, its result ispassorblocked:..., and the toolchain file'sdriverandtileiraslines agree with it.ledger-sm90.txtandledger-sm100.txtare the records of two other machines, and no gate reads them. scripts/tileir-features/check.py(CI) holds three coverage ledgers, of opcodes, type tags and attribute values, against the writer's opcode table in both directions.dawn test --stdlibrunsstd/gpu's test blocks: the fake device's behaviour, and the real handler's refusals that do not reach the device.
Not promised:
- That the fake device agrees with a kernel. The fake device runs only the reference; the
agreement between reference and kernel is shown only by layer 2, on the machines its ledgers
record, and there is no evidence for any architecture, driver or
tileirasversion outside them. --checkaccepts a ledger whose last line isblocked:...: it shows that somebody ran layer 2 at this tree and what the driver said, not that the device's answer reached CI.- The fold order of reductions and scans: Tile IR does not fix the tree shape, and the bitwise result of a tolerance-tier kernel is undefined.
- The real device's behaviour when the arguments disagree with the kernel's entry in number or format: undefined.
- That an
i64beyond ±2^53 is computed on by the fake device: it holds one and refuses to launch on it. - Recording, encoding or running a kernel at comptime.
13.Syntax cheat sheet#
# ---- declarations ----
use geo/shape.{Shape, area}
use java "java.nio.file.Files"
pub type Color = Red | Green | Blue derive Show
type Point = { x: Float, y: Float }
alias Distance = Float # transparent alias (§2.6)
pub opaque type UserId = Int # opaque outside this module (§2.7)
const ORIGIN: Point = Point { x: 0.0, y: 0.0 }
pub trait Named[T] { fn name(x: T) -> String }
impl Named[Point] { fn name(p: Point) -> String = "point" }
pub effect Ask { fn ask() -> Int }
pub fn dist(a: Point, b: Point) -> Float =
sqrt(pow(a.x - b.x, 2.0) + pow(a.y - b.y, 2.0))
fn double(x: Int) = x * 2 # a private function may omit the return type (§3.1)
# ---- expressions ----
let n = 42 # immutable binding
var acc = 0 # mutable binding
acc = acc + 1 # assignment (var only)
let (a, b) = pair # destructuring
if x > 0 { "pos" } else { "non-pos" }
match opt { Some(v) -> v, None -> fallback }
xs |> filter(x => x > 0) |> map(x => to_string(x)) |> join(", ")
xs[0] # subscript: goes through the Index trait; out of range panics, enquire with get (§4.8)
[a, ..xs, if c { b }] # list elements: spread / conditional; newlines separate too (§4.11)
read_file(path)? # Result propagation
if n < 0 { return "negative" } # early return (§4.9)
xs.each { x => println("${x}") } # tail block: the last argument (§4.3)
with f <- bracket(open(p), close) # the rest of the block becomes the use closure (§4.10)
comptime { heavy_pure_calc() } # compile-time evaluation
# ---- local functions inside a block (recursive, §3.1) ----
fn sum(xs: List[Int]) -> Int = {
fn go(i: Int, acc: Int) -> Int =
if i == len(xs) { acc } else { go(i + 1, acc + xs[i]) }
go(0, 0)
}
# ---- tests ----
test "dist is symmetric" {
assert dist(p, q) == dist(q, p)
}
test dist { assert dist(p, p) == 0.0 } # named by a declaration: dist's example (§3.4)