Dawn Language Specification

中文 — the Chinese text is the original; this is its translation, and scripts/doc-check.py watches the two for drift.

Status: normative. Applies to version: 0.70.0 (the VERSION in selfhost/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. grammar.ebnf is a historical machine-readable grammar and has fallen behind the parser (its own header lists the known mismatches) — read it as a reference, not as a judge. 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.1 Source files

1.2 Comments

# Line comment, to end of line
## Doc comment, attached to the declaration that immediately follows (extracted by the toolchain)

1.3 Identifiers 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):

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 by Character.isLetterOrDigit or _. So fn 中文() and fn _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.4 Keywords

fn let var type alias const use java pub
match if else for in while with
return break continue
comptime unsafe_pure test assert
trait impl effect
true false not

Keywords cannot be used as identifiers. panic and todo are built-in functions, not keywords. There are four further contextual keywords, which remain ordinary identifiers elsewhere: derive (only at the tail of a type declaration), as (only in the renaming position of use, §10.2), handle (after with, when the next token is not <-, §6.5), and opaque (only directly before type, where it introduces an opaque type, §2.7).

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.5 Literals

FormTypeNotes
42, 1_000_000, 0xFF, 0b1010Int64-bit signed; underscores may be used as separators
3.14, 1.0e-9FloatIEEE 754 double
true / falseBool
"hello"Stringsee §1.6
()Unitthe 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}'Charcharacter 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 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/charchar.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/str.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.char is the exception: it returns Int, not Char. 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. The Char surface is str.at, str.chars and code_points; cursor.char is the primitive underneath them.

History: up to and including v0.56.0, 'a' was an Int (Go's rune route). The type name Char landed in v0.54.0 and the std/char module in v0.56.0, each one release early and as a transparent spelling of Int, 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.6 Strings 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 is introduced by $ (as in Kotlin/Swift): $name inserts a simple identifier, ${expr} inserts an arbitrary expression. 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)}")

When $ is not followed by an identifier or { it is a literal dollar sign ("$5" needs no escaping); to force a literal $ use \$. 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.7 Newlines 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 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.8 dawn 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 only alters the whitespace between tokens — intra-line spacing, 2-space indentation, collapsing consecutive blank lines (the author's physical line breaks are kept). Formatting therefore preserves tokens, preserves comments, and 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.1 Basic types

The non-generic, compiler-owned basic types users may name directly are Int (64-bit), Char (a Unicode scalar value), Float (double), Bool, String, Bytes (an immutable byte sequence), and Unit. These seven 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 are only two exceptions, and neither is a restriction of the representation:

There is no null. A value of any type is necessarily valid; possible absence is expressed with Option[T]. There are no implicit conversions. IntFloat must be written explicitly as to_float(n).

2.2 Composite types and naming layers

The generic, compiler-owned types users may name directly are exactly List[T], Map[K, V], and Set[T]:

The remaining composite types do not belong to that builtin-name inventory:

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:

2.3 Sum types (ADT)

type Shape =
  | Circle(r: Float)
  | Rect(w: Float, h: Float)
  | Point                       # constructor with no payload

2.4 Records

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. 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). 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, 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, bind it first with let g = r.f; to call the function, name it directly as f(r, x).

2.5 Generics

2.6 Type 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 = Float provides no unit safety whatsoever. It is fully interchangeable with Float: Meters and Seconds can be added together, and passing a bare Float where a function takes Meters goes 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 type of the next section: pub opaque type Meters = Float is interchangeable outside its module with neither Float nor Seconds, 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). Now type X = <fn type/tuple/Name[...]/built-in scalar> is a compile error whose hint points at alias; type Color = Red keeps 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.7 Opaque 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, ordering and rendering, on both backends, at zero cost. opaque is a soft keyword; only opaque type means anything.

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 = T in place with alias N = T; if some function's answer changes, it is either one of the five things below, or it is a bug. Only five things are allowed to see TyOpaque: 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, and export-surface visibility (§3.3: it checks the identity and the explicit arguments, not the representation). Every other function that eats a Ty — 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 in scripts/opaque-twin/: every corpus program is run twice, once as written and once with alias substituted, and the outputs must agree (a compile error counts as output). Doing this by hand once on 2026-07-27 caught 12 places.

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. "The rendering is the target's too", above, is stated on the premise that the type wrote none of its own: Char wrote both (impl Display[Char] and impl Show[Char], §1.5), so 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 — Cursor was 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.

Cursor has already been migrated (§11): the compiler's version was deleted outright and std/cursor stands it back up using the mechanism of this section, the first real user of the feature.

Array is 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, and Array has no target, it is the representation; the two also point in opposite directions, in that this mechanism publishes the name and hides the representation, while Array's is_std_module gate 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.1 Functions

fn add(a: Int, b: Int) -> Int = a + b

fn greet(name: String) -> Unit !io = {
  println("hi, $name")
}

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)

Local named functions: a block may contain a fn name(params) -> T [!io] = 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 can only be !io or pure (lift to the top level for effect polymorphism); 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.2 Constants

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.3 Visibility

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:

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.4 Test blocks

test "precedence" {
  assert eval("2+3*4") == Ok(14)
}

3.5 trait 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)*]
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

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:

Where it applies:


4. Expressions

Dawn is expression-oriented: if, match and blocks all produce values.

4.1 Bindings

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

4.2 Blocks

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.3 Operators and precedence

From lowest to highest:

PrecedenceOperatorAssociativityNotes
1|>leftpipeline, see §4.4
2||leftlogical or, short-circuiting
3&&leftlogical and, short-circuiting
4== != < <= > >=non-associativecomparison; chained comparison is a syntax error
5|leftbitwise or; Int only
6^leftbitwise xor; Int only
7&leftbitwise and; Int only
8<< >> >>>leftshifts; Int only. >> is arithmetic (sign-filling), >>> logical (zero-filling)
9++rightString/List concatenation
10+ -leftnumeric only, both sides the same type
11* / %leftnumeric only; Int division by zero panics
12not, unary -, ~prefix~ is bitwise complement, Int only
13? . () [] callpostfix? see §8.1; () has been a general postfix since 2026-07-30 (see below)

Numeric edge semantics (all of them guaranteed — after selfhosting the two implementations are cross-checked against this, "happens to agree" is not allowed):

4.4 Pipelines

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:

4.5 Lambda

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

4.6 if

let sign = if x > 0 { 1 } else if x < 0 { -1 } else { 0 }

4.7 Loops

for x in [1, 2, 3] { println("$x") }
for (key, value) in entries { println("$key=$value") }
while queue.non_empty() { ... }

Idiomatic style prefers map/filter/fold; loops are there for performance-sensitive spots and for taste.

4.8 Indexing

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 ?/./()

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:

  1. 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 for cursor.slicepanic (negative indices included).
  2. Enquiry — out of range / a missing key is a normal branch the caller wants to tell apart: the get family (list.get, map.get, absence in set.has, a miss in the index_of family (str/bytes/list), a non-match in str.strip_prefix/ strip_suffix, malformed input to bytes.from_hex/from_base64 and to fspath.extension (package)) → Option (or Bool).
  3. 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 becomes 0, an over-long one len), from > to gives 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.9 return

fn classify(n: Int) -> String = {
  if n < 0 { return "negative" }   # guard clause
  if n == 0 { return "zero" }
  "positive"
}

4.10 The 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()!
})

Why bracket and not defer: the protected interval is always exactly one closure call, return/break cannot get out of it at the language level, and so the compiler owes no escape-rewriting pass. The criteria are in docs/core-move2-design.md §2.6 and §6.

4.11 List 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 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 type List[Unit], and always of length 1 — the if inside 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, an if without an else in expression position could only be Unit (§4.6). Landing this, every .dawn file 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 — Diag carries msg/lo/hi/hint and everything is an error. And making "a conditional element whose body is Unit" an error would carve a permanent hole in the new form's typing rule to protect a class of programs measured not to exist; a List[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.1 Pattern forms

PatternExampleMatches
Literal0, "yes", truematches if equal
Bindingxalways matches, and binds
Wildcard_always matches, binds nothing
ConstructorSome(x), Rect(w, h), Rect(w: w, ..)destructures by position or by name, .. ignores the remaining fields
RecordPoint { x, .. }field destructuring
Tuple(a, b)
List[], [x, ..rest]empty list / head and rest
Or0 | 1 | 2any alternative matches (the bindings of every alternative must agree)
Guardpat if condthe 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. 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.2 Exhaustiveness

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.1 Model

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.

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.

6.2 Rules

  1. The effect of a function body = the union of the effects of every call in it.
  2. 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 !io to the signature or eliminating that call).
  3. Marked !io but the body is pure → allowed (room reserved for evolution); a "redundant !io" lint needs type analysis, and the current dawn fmt --check only checks formatting — that hint is not implemented (left for later).
  4. 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. 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 the with handle that really answered it (§6.5), so a function whose signature says pure cannot run somebody else's handler arm.
  5. 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).
  6. panic/todo/assert do not count as io — they do not return (divergence is not an effect).
  7. 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 !io over 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.

6.3 Effect 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)

6.4 Escape hatch: unsafe_pure (std only)

unsafe_pure { <expression> } is the expression block for pure FFI: the author guarantees the wrapped expression is pure, and on that basis the type system masks its effect from !io to pure, so one host interop call can support a pure function. For the design see `docs/pure-ffi-design.md`.

Not available to user code (narrowed 2026-07-30, LANG-01): this stamp unconditionally erases an effect the checker had proved, and every inference that purity licenses (folding, reordering, dropping calls) will believe it — that is a soundness hole, and design.md's original verdict was already "the unsafe escape is not opened to user code". It is legal only inside a bundled std module (is_std_module); appearing in a user module is a compile error. std is the only code that ships with the compiler, bootstraps with it and is guarded by the same N vs N−1 differential comparison — the guarantee is only reconciled by someone if it is kept there. If a pure wrapper really is needed outside std, it should become an std function (no escape valve: giving one would be the same as not narrowing).

use java "java.lang.Math"

pub fn sqrt(x: Float) -> Float = unsafe_pure { Math.sqrt(x) }   # legal only inside an std module

And std does not use it today either. The example above used to be real code in std/str; today std has not one unsafe_pure and not one use java — those operations have become part of the intrinsic contract (§11), honoured by the backend instead of vouched for one call site at a time. So unsafe_pure has zero use sites in the whole ecosystem: it is kept as a mechanism for future std low-level wrappers, not as language surface.

What is wrapped must be a static method call: Dawn's native types (String/List/Bytes/Map/Set) are not Java types, so an instance call like s.substring(…) does not work today (pure-ffi-design.md §9).

6.5 Named effects and with handle

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. This tier is tail-resumptive: a handler arm is an ordinary closure that is "called in place, its return value is the operation's result", with no continuation capture. For the design and the verdicts see `docs/effects-design.md`.

Declaration

effect Ask {
  ## Ask the context for an Int.
  fn ask() -> Int
}

effect State {
  fn get() -> Int
  fn put(v: Int) -> Unit
}

Spelling and propagation

fn sum_three() -> Int !Ask = ask() + ask() + ask()

fn logged(x: Int) -> Int !Ask !io = {
  io.println("asking")
  ask() + x
}

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.

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:

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.

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.

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.

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.6 Row 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.

  1. 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, and pure is the identity. Stacked annotations equal one union: !Ask !io is !(io | Ask).
  2. 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.
  3. 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 !e prints as !(e|io).
  4. 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.
  5. 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:

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.1 Form

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.2 Constraints

  1. comptime code must be pure (it may call any pure function, including those of this module and of dependency modules).
  2. The result type must be constant-serialisable — that is, the compiler can rebuild the value at class initialisation time: Int/Float/Bool/String/Unit, plus List/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's Cursor is not. Its Int is 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, or const 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/Set are not allowed for now: they are HAMTs over Array, and the comptime interpreter has no Array primitive; List works because the interpreter carries its own list representation, not because it can run std/pvec.
  3. Evaluation has a step budget (10⁸ steps by default, tunable with --comptime-fuel); exceeding it is an error — which guarantees compilation always terminates.
  4. There is no Java interop and no io inside comptime (constraint 1 guarantees this automatically).

7.3 Explicitly 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.1 Recoverable: 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)
}

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_err was once added to std for this, and reverted on 2026-07-19: of the 102 cross-layer matches 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 of map_err was 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.2 Unrecoverable: panic

panic(msg): prints the message and a Dawn-level stack trace; the process exits non-zero. todo() is equivalent to panic("not yet implemented") and passes any type check (its return type is the bottom type Never).

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), 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.

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).


9. Java interop

9.1 Importing 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")
}

9.2 Type mapping

DawnJavaDirection
Intlong (an incoming int is widened automatically)both ways
Floatdoubleboth ways
Boolbooleanboth ways
Stringjava.lang.Stringboth ways
Unitvoidreturn
an imported class Ta reference to that classboth 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:

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.3 Overload 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.4 SAM 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()
}

9.5 Arrays: 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.1 Bytes: 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), bytes.len, 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) -> 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) — cast used to throw ClassCastException, 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.6 The 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]. Zero-copy: the bridge wraps it in an unmodifiable view (Collections.unmodifiableList), and the mutating methods on the Java side throw UnsupportedOperationException — the same convention as Scala's asJava and Clojure's persistent collections.

9.7 Limits

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.8 The 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 gets you "java_try is not a builtin; renamed to catch_fault".

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 })

The companion catch_panic[T, !e](f: fn() -> T !e) -> Result[T, ForeignError] !io (the same shape, for the same reason) 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 (Error versus Exception); native has no exceptions, every failure travels the same longjmp, 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 in scripts/spike-native/catch_kinds.dawn; before it was written, native's catch_fault intercepted every panic that should have passed through.

9.8.1 The 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] }

The payload contract (on every backend):

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 pinned to !io, not a variable — a pure closure included: what catching a panic yields depends on the depth of the call stack, on the file:line baked into the message, and on how many times the optimizer folded a pure call. None of the three is a difference a pure function is allowed to have. That is why the bracket of §9.8.2 can be effect-polymorphic and these two cannot: bracket does not observe the failure. 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 ForeignError took 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 a Result[T, String] table and a Result[T, ForeignError] table at the same time. So the new shape landed first under the transitional spellings catch_fault_e/catch_panic_e with 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 in dawn doc --builtins for v0.32.0/v0.33.0 and does not exist after that. The staging is in docs/audit/error-model-design.md §6.

9.8.2 Releasing, 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:

It gets no surface syntax like defer: the protected region is always one closure call, so return/?/break cannot cross out of it at the language level, and the compiler owes no escape rewriting. The criteria, and the earlier conclusion they overturned, are in docs/core-move2-design.md §2.6 and §6.


10. Module system

10.1 Files 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:

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).

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.2 Imports

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

10.3 Name resolution (disambiguation rules)

10.4 Visibility

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). 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.

Load scope (2026-07-30, LANG-07): dawn run/test/build <dir> loads every module under src/ by default — modules that are never referenced are checked too (bit-rot protection, and that is the right default). --closure narrows it to "the use closure of the entry src/main.dawn", for large projects producing an artifact; dawn check is always whole-repo. Recommended for CI: dawn check guards the whole repo and dawn build --closure produces the artifact. dawn check exits 1 on any diagnostic (§12.1), so the exit code alone is enough to act on that recommendation.

10.5 Compilation units and evaluation order

10.6 The bundled standard library and the prelude

The standard library is Dawn source bundled with the compiler, organised as real modules (`stdlib-naming.md`): std/str, std/fmt, std/bytes, std/io, std/list, std/map, std/set, std/cursor, std/char. There are two further internal modules, std/hamt and std/pvec — 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.len / bytes.len), 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), and the builtin len/get/range/to_string/join/parse_*/ panic/todo/expect/unwrap_or/cast/catch_fault/catch_panic/bracket/args and the like — 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_len and 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 --stdlib and 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 with use std/x and called qualified as x.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 / parse_int_radix 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_radix uses the radix production: rdigit0-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. 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).

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; str.len 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; to_base64 uses the standard alphabet of RFC 4648 section 4 and pads with =, to_base64_url uses the url/filename-safe alphabet of section 5 and does not pad with =; the two decoders each recognise only their own alphabet (guessing the alphabet would turn misspelled input into wrong bytes), padding is optional, but 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.size(b: Buf): bytes.len is already the length 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.

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. Everything in std/io is !io, and 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:

Maths (abs min max sin cos sqrt pow to_float to_int ...) is pure — internally it wraps java.lang.Math with unsafe_pure.

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.1 Outputs

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):

CommandOutput
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.jarAn executable jar (Main-Class: main is already set)
dawn build ... --native -o appThe 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.cA 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):

CommandOutput
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 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 / lspOutput 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 versionThe 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):

SubcommandTarget / selector cardinality
check1..N targets
testExactly one target, or --stdlib; the two are exclusive
docExactly one of one target, --stdlib, or --builtins
buildExactly one target
emitcExactly one target (the JVM entry point is the hidden command __emitc)
fmt1..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, --, --comptime-ffi, and -o. 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.

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.2 The bytecode mapping

Dawn constructJVM implementation
Module json/lexerOne class, internal name json/lexer (package json, class lexer), functions become static methods
ADT/recordClass names carry the module prefix: json/lexer$Token, constructor json/lexer$Token$Num
Cross-module callinvokestatic on the other module's class; constructors/fields as usual (the classes are public)
ADTabstract class + final subclasses; a payload-free constructor is a singleton
recordfinal class + fields (does not rely on Java records, so older bytecode targets still work)
matchAn instanceof chain + field reads (no indy, no pattern switch)
lambda/closureEach closure is an ordinary generated class implementing FnN, with captures in final fields; SAM conversion generates a separate adapter class holding that FnN
genericserasure + boxing
structurally equal typesADTs/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/Boolnative long/double/boolean, boxed only in generic positions
UnitLdawn/rt/Unit; — a singleton reference; it takes one slot and is no different from any other reference in parameter/field/capture positions
NeverStatic 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
panicThrows 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.3 Ahead-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.4 Tail 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).


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)
}