The Kite Language Specification
Version: 0.1 (draft) Date: August 2026 Status: Implemented, on three backends, except where a section says otherwise. Where this document and the compiler disagree, the compiler is right and the disagreement is a bug in this file — and five of them were, found by an audit that compiled what each section claimed rather than reading a status table. Four were built and one was struck, which is recorded in Phase 26.
Table of contents
- Design rationale
- Lexical structure
- Types
- Declarations and visibility
- Expressions
- Statements and control flow
- Error handling
- Structs and methods
- Enums and pattern matching
- Traits
- Generics
- Concurrency
- Modules and packages
- Memory model
- Foreign function interface
- Diagnostics
1. Design rationale
1.1 The concept budget
A language's difficulty is not measured in keywords but in concepts that must be held simultaneously to read a line of code. Go has 25 keywords but requires a beginner to understand goroutines, channels, select, value-versus-pointer receivers, nil interfaces versus nil pointers, and slice aliasing. Kite's budget is spent as follows, and this list is complete:
let/var— immutable and mutable bindings, and at module level alet
alone, which names a value the compiler works out (§4.2)
- Primitive types, slices, maps, tuples, optionals
fn— functions, including closuresif/for/match— control flowstruct+impl— data and its methodsenum— alternativestrait— shared behaviour(T, error)— fallible resultsasync/await— operations that take timepub+ modules — encapsulation
There is no eleventh concept. Everything else in this document is a consequence of these ten.
1.2 Why explicitness beats terseness here
Kite assumes code is read far more often than written, and that a significant share of it is drafted with machine assistance. Under those assumptions the costs invert:
- Typing cost approaches zero. Verbosity that would have been a burden in
1995 is now nearly free to produce.
- Reading cost dominates. A reader — human or machine — must be able to
determine what a line does without holding the rest of the file in memory.
- Hidden control flow is the expensive thing. An exception that unwinds
through six frames, an implicit conversion, an overloaded operator, a destructor with side effects: each forces the reader to consult code that is not on screen.
So the language is built on what a reader can see. Every call is a call you can point at. Every failure path is written on the line where it happens. Every allocation is an expression. A name resolves to one declaration, an operator does one thing, and a value changes only where a var says so.
1.3 Why immutable by default
This decision, made once, pays three times:
- It eliminates the pointer/value distinction. Go beginners must learn when
to write func (p *Point) versus func (p Point). In Kite structs are always GC references and always passed by reference, but you cannot mutate one unless its fields were declared var. The confusing case disappears.
- It maps exactly onto WasmGC. A WasmGC
structtype declares a mutability
flag per field. Kite's var marker on a field is the same bit. Immutable fields let the engine hoist and constant-fold loads without alias analysis.
- It makes most types thread-shareable for free. See
§12.3. A deeply immutable value is safe to share by construction. Because immutability is the default, the overwhelming majority of user types qualify without the user ever thinking about it.
2. Lexical structure
2.1 Source encoding
Source files are UTF-8. The file extension is .kite. A file may begin with a byte-order mark, which is not part of the program. Identifiers may contain any Unicode XID_Start / XID_Continue characters, so non-Latin identifiers are supported. Source is normalised to NFC before comparison, so visually identical identifiers are the same identifier.
2.2 Keywords
Twenty-seven, complete:
async await as break check continue
defer else enum false fn for
if impl in let match nil
pub return self struct trait true
type use var
2.3 Comments
//! Module documentation: the file's own overview, written at its top.
// Line comment.
/// Documentation comment. Attaches to the following declaration.
/// Markdown is permitted. Code fences are extracted and compiled as tests.
There is no block comment: /* is E0005.
A `kite fence is compiled and run by kitec test, alongside the file's test_… functions. It is appended to the module it was written in, so everything the comment documents is in scope with no import to get wrong; a fence that declares a type or a function lands at file scope and is checked by compiling. `kite ignore marks an illustration instead — a fence naming types the reader supplies, which most module headers do — and is not compiled. A fence tagged anything else is prose.
2.4 Literals
42 // int
1_000_000 // underscores permitted as separators, between two digits
0xFF 0o755 0b1010_1101
3.14 // float
1e10 1.5e-3
"hello" // str
"line\nbreak"
"""
multi-line string, leading indentation stripped
to match the closing delimiter
"""
t.0.1 // a tuple index after a tuple index, not the float `0.1`
true false
nil
String interpolation uses \(expr):
let name = "world"
io.print("hello, \(name), you are \(age) years old")
Interpolation calls Display.show on the operand: a hole is an ordinary expression, evaluated where it stands, and holds exactly one.
A block string is dedented the same way with holes in it as without: the line break after the opening """ goes, the closing delimiter's line goes, and that line's indentation comes off the front of every line.
int, float, bool and str render themselves; every other type renders through its own Display. Because a hole is an expression, "\(if n > 1 { "s" } else { "" })" is a pluraliser and needs nothing added to the language.
2.5 Semicolon insertion
Statements are newline-terminated. Semicolons are never written. A statement continues onto the next line when the line ends in an operator, an open delimiter, or a comma. The same rule as Swift and Kotlin, and unambiguous here because a statement never begins with ( or [. > and >> continue a line as the other operators do, and the > that closes Option<int> ends one; return is not an operator, and a line ending in it ends there.
The operator ends the line it continues, and || is why that is a rule and not a style. A line opening with || is not the tail of the expression above it: || where a value is expected is a closure with no parameters, so the line parses, means nothing, and is discarded.
let ok = (c >= 48 && c <= 57)
|| (c >= 65 && c <= 90) // error[E0117] — a closure, thrown away
The first line is already a complete statement. && cannot do this, because it may not begin an expression and so is a syntax error; || can, and it is the one continuation a reader coming from another language writes by habit. It is rejected as E0117 rather than allowed to be quietly wrong.
3. Types
3.1 Primitives
| Type | Description | Wasm representation |
|---|---|---|
bool | true / false | i32 |
int | 64-bit signed | i64 |
float | 64-bit IEEE-754 | f64 |
str | Immutable sequence of Unicode scalar values | GC array i32 |
error | A failure, and what it says (§7) | GC reference |
JsValue | An opaque host object; web only (§15.1) | externref |
There are one integer type and one float, and nothing converts between them implicitly. let x: float = count is a compile error; write count as float. Sized numerics (i32, u8, f32) and char are not part of the language, and a numeric literal may not carry a type suffix: 42i32 is E0004.
Integer overflow traps in debug builds and wraps in release builds, matching the default most users expect while keeping release performance predictable. The rule covers every operation that can overflow, on every target:
| Operation | Debug build | Release build |
|---|---|---|
a + b, a - b, a * b past the range | traps | wraps |
-a where a is int's minimum | traps | wraps: -min is min |
a << n, a >> n with n outside 0..=63 | traps | n is taken modulo 64, its low six bits: 1 << 65 is 2 |
a / 0, a % 0 | traps | traps |
min / -1 | traps | traps |
min % -1 | 0 | 0 |
Division is the exception to wrapping: there is no quotient to wrap to when the divisor is zero, and min / -1 traps with it so that a quotient is always the true one. A remainder by -1 is always 0, min's included — the answer fits, so it is not an overflow. >> is arithmetic, keeping the sign. A module-level constant (§4.2) is the same in every build, so a shift count outside 0..=63 in one is a compile error, E0118, as a division by zero is.
-9223372036854775808 is int's minimum. The digits alone are one past the largest int and are refused (E0004), but a - written directly in front of them is read with them as one constant. math.wrapping_add wraps in both, and math.checked_add answers Option<int> in both, for the code that has to mean one of the two regardless of how it was built. Both are ordinary Kite over math.max_int() and math.min_int(), and neither may overflow while deciding whether an overflow would happen — which is why they subtract to ask rather than adding and looking.
On str: the Wasm target has one language-owned representation: a WasmGC array containing one Unicode scalar value per i32 element. Literals, concatenation, equality, ordering, length, slicing, searching, trimming and code_at all operate inside the module. The array is traced by the engine and is reclaimed when its last Kite reference disappears; no permanent host table or JavaScript-string root exists.
JavaScript strings appear only at a declared host or export boundary. The generated glue converts synchronously through one fixed 64 KiB scratch page in 4,096-scalar chunks, and retains no converted value after the call. On native and bytecode targets the storage is GC-managed UTF-8. Kite programs observe the same rule everywhere: indexing and length count Unicode scalar values, never bytes or UTF-16 code units.
What a str can do, and it is deliberately little:
| Operation | Meaning |
|---|---|
s.len() | Characters, not bytes and not UTF-16 code units |
s.slice(from, to) | Characters from..to, clamped rather than trapping |
s.index_of(needle) | The character index, or -1 |
s.trim() | Leading and trailing whitespace removed |
s.code_at(i) | The code point at character i, or -1 past the end |
Everything else — split, starts_with, replace, join, words, parse_int, case folding — is written in Kite on top of these and lives in the prelude, where it can be read. Each of these is a boundary two runtimes have to agree about, and every one added is a thing that can drift.
code_at is the one that is not a string operation at all, and it is here because it is the one thing nothing else can be built from: without a way to see a character as a number, a hash, an ordering and a number parser each have to become a boundary of their own. One general primitive is cheaper than three special ones.
3.2 Composite types
[T] // slice — a copy-on-write sequence
{K: V} // map — hash map with deterministic iteration order
(A, B, C) // tuple
Option<T> // optional — either a T or nil
fn(A, B) -> C // function type
Maps iterate in insertion order. Go randomises map iteration to prevent reliance on order; Kite instead guarantees an order, which is cheaper to reason about and removes an entire class of nondeterministic test failure.
There is no fixed-length array. A [N]T was listed here for a long time and never existed in the compiler, and the resolution was to strike it rather than build it — because this document already said twice that it should not be here. §1.1 lists the composite types a reader must hold and calls the list complete, and an array is not on it; §14 records that WasmGC gives [Point] an array of references, so a fixed length buys no layout, and names buffer.F64 as the answer for code where the layout is the point. What was left was a compile-time length check, which is not worth an eleventh concept.
3.3 Optionals
Option<T> is the only place nil may appear other than the error slot (§7). There is no null reference. A Config is always a Config; an Option<Config> might be nil, and the compiler will not let you use it as a Config until you have handled that.
An optional is opened by testing it, and an if expression does that in one line:
let maybe: Option<User> = users.find(id)
// The compiler narrows `maybe` to `User` in the branch where it cannot be nil.
let name = if maybe == nil { "anon" } else { maybe.name }
match maybe {
nil => io.print("not found"),
user => io.print(user.name), // `user` is bound as User, not Option<User>
}
Narrowing is what makes this ergonomic rather than tedious. Testing an optional against nil narrows it to the unwrapped type on the branch where it cannot be absent — in the else of x == nil, and in the then of x != nil. The same narrowing applies in a match arm once an earlier arm has covered nil. A write to a var ends its narrowing unless the value written cannot be nil either; inside a loop, that holds for every write the loop's body makes, since the body runs again after each of them.
3.4 Type declarations
type UserId = int // alias — interchangeable with int
type Celsius = float // alias
type Prices = {str: int} // any type, not just a primitive
pub struct Point {
x: float
y: float
}
pub enum Status {
Active
Suspended(reason: str)
Deleted(at: Timestamp, by: UserId)
}
An alias is replaced by the type it names before anything else is checked, so the two are the same type everywhere — a UserId adds no safety over an int, and is not a way to get one. Aliases may name each other and may be declared in any order. An alias of a struct or an enum stands for it where its name is written in a body or a header too: type Pt = Point makes Pt{ x: 1.0, y: 2.0 }, impl Display for Pt and, for an enum, S.Active mean what Point and Status would. An alias of one instantiation of a generic type, type Ints = Box<int>, names a concrete type for Ints.is(err) and Ints.as(err); as an impl header it is that instantiation written out, and E0208 (§8.2). An alias of anything else, impl Display for UserId, is no type an impl is for (E0204).
Two forms are rejected, both because the replacement is the whole feature. A circular alias (type A = B with type B = A) names nothing to be replaced by. A generic alias (type Pair<T> = (T, T)) would need its arguments substituted through the alias at every use, which is a second instantiation path alongside the one structs and enums have; Kite has one. Both are E0214.
4. Declarations and visibility
4.1 Bindings
let x = 42 // immutable; type inferred as int
let y: float = 3.0 // immutable, explicit type
var count = 0 // mutable
count = count + 1
let z: int // declaration without initialiser
if condition {
z = 1
} else {
z = 2
}
// `z` is definitely-assigned here and immutable from now on
Deferred initialisation of a let is permitted provided the compiler can prove exactly one assignment occurs on every path before first use. This removes the main reason people reach for var.
Shadowing within a nested scope is permitted. Shadowing within the same scope is an error — it is almost always a typo.
4.2 Module-level constants
A let at the top of a module names a value the compiler works out while compiling. Every use of the name is that value: the name is replaced by a literal, so nothing is looked up and nothing is allocated at run time.
let NAMESPACE = "payments:"
pub let ACT_SETTLE = "\(NAMESPACE)settle"
pub let MAX_BODY: int = 1 << 20
The right-hand side must be one the compiler can work out: a literal, an operator applied to constants, an interpolation whose holes are all constants, or another constant — including one from an imported module, limits.MAX_BODY. A call is not, even one that would always return the same answer. Running a program's own code during its compilation is a second evaluation order, and which functions were available to it would become a language rule nobody could predict.
The type is optional and read from the value when it is left out. There is no implicit conversion here either: let X: float = 3 is E0200.
Four types are allowed — bool, int, float, str. A slice, map or struct constant would be an allocation, so it would have to be either a fresh value at every use, which is surprising for something spelled like a name, or one shared object, which is what the next paragraph refuses. A slice of constants is an ordinary let inside the function that wants one.
There is no module-level var (E0118). A mutable binding every function in a module can reach is state that none of their signatures mentions — the same thing the closure capture rule (§4.5) and the Share marker (§12.3) exist to prevent. Put it in a struct and pass it to what changes it.
A constant shares the value name space with functions, so a module cannot declare both fn limit and let limit; a cycle among constants is E0119.
A float interpolated into a constant is written the way every backend writes one at run time, so let LABEL = "max \(1e21)" is "max 1e+21" everywhere:
NaNisNaN, and the infinities areinfand-inf.- Zero is
0.0, and negative zero-0.0— it is a different value, and
1.0 / -0.0 says so.
- A whole number below
1e21in magnitude is written with all its digits and
.0, so that it reads back as a float: 3.0, 100000000000000000000.0.
- Anything else is the shortest decimal that reads back as the same value,
written plainly from 1e-7 up to 1e21 and in exponent form outside it: 0.30000000000000004, 0.000001, 1e-7, 1.5e-7, 1e+21, 5e-324, 1.7976931348623157e+308. Every one is a valid float literal. Of two shortest decimals, the closer is written, and of two exactly as close, the one whose last digit is even: 1125899906842624.25 is 1125899906842624.2.
This is also the text of io.print(x) and "\(x)" for any float, on every target. It is ECMAScript's Number#toString except for Kite's own spellings — inf, -inf and -0.0 — and a whole number below 1e21, which is written with its exact digits and .0 (123456789012345683968.0 where JavaScript writes 123456789012345680000). It was not always: the bytecode VM and the native runtime once wrote inf, -0.0 and 0.0000001 where the browser wrote Infinity, 0.0 and 1e-7, and a float in a constant was refused because folding it would have had to pick one.
4.3 Visibility
pub is the only visibility modifier. There are exactly two levels:
- Unmarked — visible within the declaring module (a directory).
pub— visible to anything that imports the module.
pub applies to modules, functions, types, struct fields, enum variants, traits, and trait methods. A pub struct with unmarked fields is an opaque type: callers can hold it and pass it, but cannot read, construct, or destructure it — not with a literal, not with a ..base update, not in a pattern.
Methods and associated functions in an impl block are unmarked, and so private to the module, unless they say pub. The methods of a trait implementation are as visible as the trait. A type that is not pub cannot be named outside its module at all — not in an expression, and not in a signature, a field or an annotation either. Each of these is E0401.
pub struct Connection {
pub host: str // readable by importers
socket: Socket // module-private
var retries: int // module-private and mutable
}
Two levels, and they compose with the module system rather than with a second hierarchy: what a module exports is what pub marks, and what it keeps is everything else.
4.4 Functions
pub fn add(a: int, b: int) -> int {
return a + b
}
fn greet(name: str) { // no return type means it returns nothing
io.print("hello \(name)")
}
pub fn divide(a: int, b: int) -> (int, error) {
if b == 0 {
return _, errors.new("division by zero")
}
return a / b, nil
}
Parameters are immutable inside the body unless declared var. There are no default arguments, no variadic parameters, no named arguments at call sites, and no overloading. If a function needs many optional inputs, it takes a struct:
// std/http
pub struct Options {
/// `name: value` pairs, one per line.
pub headers: str
pub credentials: Credentials
pub redirect: Redirect
}
/// The defaults, to be changed where a caller cares.
pub fn sending() -> Options
pub async fn send_with(method: str, url: str, body: str, options: Options) -> (Response, error)
// call site
let (res, err) = await http.send_with("POST", url, body, http.Options{
..http.sending(),
headers: "content-type: application/json",
credentials: http.Credentials.Include,
})
Struct literals require field names, so this reads as well as named arguments would, using machinery the language already has — and a functional update from a function that returns the defaults makes every field optional, with the call site naming only what it changes.
A length of time is an int of milliseconds. There is no Duration type: std/time names the units in the functions that build one — time.seconds(30) is 30000 — and a wrapper around an int would buy nothing the name does not.
4.5 Closures
let double = |x: int| -> int { return x * 2 }
let triple: fn(int) -> int = |x| x * 3 // types from the annotation, expression body
let total = fold(items, 0, |acc, item| acc + item.price) // types from `fold`'s other arguments
A closure's parameter types come from the place it is used: an annotated binding, or the parameter it is passed to once the other arguments have fixed that parameter's type. Where nothing fixes them, |x| x * 2 is E0211, and the parameter is annotated instead.
Closures capture by value, taken when the closure is made. Because let bindings are immutable, the vast majority of captures are trivially safe: the value cannot change afterwards, so by-value and by-reference cannot be told apart.
Capturing a var is a compile error (E0211). A by-value capture of a mutable binding would not see later writes, and code that reads one expecting it to is a bug that no diagnostic could find afterwards. Promoting the binding to a heap cell would make the write visible, and was specified here before the compiler was built — but it buys shared mutable state through a capture list, which is the thing this language spends most of its omissions avoiding.
var total = 0
let add = |n: int| { total = total + n } // error[E0211]
For the same reason a closure may not assign to a binding it captures, let or var (E0211): it holds a copy, and a write to the copy is seen by nothing.
To let a closure change something, capture a let handle to a struct and pass it to a function that takes it as var. Structs are references (§14), so the write lands where the holder can see it, and it happens through a named function rather than through a capture:
struct Counter {
var count: int
}
let state = Counter{ count: 0 }
let bump = || { increment(state) } // captures a `let`, by value
fn increment(var c: Counter) {
c.count = c.count + 1
}
This is the idiom for every event handler, timer and observer callback a program writes, and it is deliberate that mutation is spelled out in a signature rather than implied by a capture.
A closure that captures a host reference is not Share (§12.3).
5. Expressions
5.1 Operator precedence
Highest to lowest:
| Level | Operators | Associativity |
|---|---|---|
| 1 | a.b a(…) a[…] | left |
| 2 | -a !a | prefix |
| 3 | as | left |
| 4 | * / % | left |
| 5 | + - | left |
| 6 | << >> | left |
| 7 | & ^ | | left |
| 8 | == != < <= > >= | non-associative |
| 9 | && | left |
| 10 | || | left |
| 11 | .. ..= | non-associative |
Bitwise operators bind tighter than comparison, unlike C. a & b == c means (a & b) == c, which is what everyone intends and C gets wrong. Comparison is non-associative: a < b < c is a syntax error, not a silent bug.
A range is the loosest operator there is, so 0..n + 1 is 0..(n + 1), which is how it reads. It is non-associative too: a..b..c has no meaning to give.
5.2 Equality
== is structural for all types: two structs are equal when their fields are equal, two slices when their elements are, two maps when they hold equal entries in the same insertion order. Order counts for a map because it is part of what a map is — iteration, keys() and a derived hash() all observe it — so {"a": 1, "b": 2} != {"b": 2, "a": 1}. A recursive type — a list whose tail is another list, a tree whose children are trees — is compared the same way, as deep as the values go. There is no reference equality operator in the surface language; ptr.same(a, b) is a compiler builtin, for the rare case that needs it.
ptr.same answers whether two names refer to one heap cell, which == cannot express: two distinct values with identical fields are equal and are not the same cell. Both arguments must have the same type, and that type must be a struct, enum or map — the three that are a cell two names can share. Everything else is rejected — E0213 — each for its own reason: a number or a str has no cell; a slice has one but is copy-on-write, so two sharing a buffer is an allocator fact that a write to either would end; a function and a dyn have no stable identity to report, which is why == is undefined on them too.
The motivating case is a fixpoint. A loop that repeats while a value keeps changing must ask "is this the value I passed in?", and structural equality answers a different question at the cost of walking the whole value.
Structural means a T compares with an Option<T> as it would be passed to one: found == 5 is true exactly when found is present and five.
== is defined on everything but a function, a dyn Trait and a JsValue, or a value holding one (E0201): a function has no identity to compare, a trait object is a record made where it was converted, and a host object has no structure Kite can see. A map compares its keys, so the same three cannot be keys. A generic function that compares its T with == is held to that at every call — T may not be chosen as one of the three — and so is a generic function that passes its own parameter on to one that compares it. A trait's generic method called through a bound is held to what any of its implementations compares, and a generic type standing for a trait, for a bound or as a dyn, to what its implementation's methods compare of its own arguments. A call the compiler writes counts as one the program writes: io.print(b) and "\(b)" call b's show, and a value becoming an error calls its message.
Floating-point == follows IEEE-754, so nan != nan. The compiler emits a warning when both operands of == are statically known to be floats and neither is a literal, suggesting the prelude's approx_eq(a, b, tolerance).
5.3 Struct literals
let p = Point{ x: 1.0, y: 2.0 }
// functional update — produces a new value, does not mutate
let q = Point{ ..p, y: 5.0 }
All fields must be given unless ..base is used. There are no zero values in Kite — a struct literal that omits a field without .. is a compile error. This removes Go's most common production bug, where a forgotten field silently becomes 0, "", or nil.
5.4 Slices and maps
let xs = [1, 2, 3]
let ys: [int] = []
let m = {"a": 1, "b": 2}
xs[0] // int — bounds-checked, traps on failure
xs.get(0) // Option<int> — bounds-checked, nil on failure
xs[1..3] // [int] — subslice, half-open, clamped
xs[1..=2] // [int] — the same subslice, inclusive
xs[1..] // [int] — from index 1 to the end
xs[..2] // [int] — the first two
xs[..] // [int] — all of it
xs.len() // int
m["a"] // Option<int> — map indexing always yields an optional
m.remove("a") // takes the entry out; a key that is not there is not an error
Map indexing returns Option<V>, never a zero value.
remove shifts the entries after it down, so insertion order keeps meaning what it says and keys() and values() still line up element for element. Its receiver must be somewhere a change can be kept, exactly as xs.push(v)'s must: both are copy-on-write values, so changing the contents changes what holds them (below). Assigning nil is not the same thing — on a {str: Option<int>} it leaves the key in place with a nil value, and len() does not move. Slice indexing with [] traps on out-of-bounds because that is a program bug, not a runtime condition; .get() is provided for the case where it genuinely is a runtime condition.
A range index clamps where a single index traps, and the difference is the question each one asks. xs[i] names an element the program believes is there, so a missing one is a bug. xs[a..b] names a window, and a window wider than the data is what paging code produces on its last page — trapping there would make every caller write the clamp. So xs[2..100] is the tail, xs[4..1] is empty rather than an error, and a negative start is the beginning. This is the same rule s.slice(from, to) has had all along (§3.1), and s[a..b] is that call written as an index: one syntax with two answers about its edges is precisely the drift this language spends its omissions avoiding.
Either end of a range index may be left out: xs[a..] runs to the end, xs[..b] starts at the beginning, and xs[..] is the whole sequence, a str as much as a slice. A missing start is 0 and a missing end the largest int, which the clamp turns into the edge of the data — so an open end is not a second rule, only the first one written shorter. An inclusive range has to say what it includes: xs[..=b] is allowed, xs[a..=] is not. Only an index may leave an end out; 0.. alone has nothing to stop at.
A slice is the only sequence a range indexes other than a str. A map has no order over its keys for a range to name, so m[a..b] is an error rather than a guess.
A slice or map is changed where it is held. xs[i] = v, xs[i] += v, xs.push(v), m[k] = v and m.remove(k) change a copy-on-write value, and so change whatever holds it — which must therefore be able to change: a var binding; a var field (§8.1) of a struct reached through a binding that may change it; or an element of a slice that is itself held one of these ways. The nesting goes as deep as the data does. grid[i][j] = v means exactly
let at = i // the operands, once each and in order
let slot = j
let value = v
var row = grid[at] // copy out
row[slot] = value // change
grid[at] = row // write back
and b.cells.push(x) means var c = b.cells / c.push(x) / b.cells = c: each level is copied out, the innermost is changed, and each is written back through the same place. The operands — every index, the struct a field is read from, and the right-hand side — are evaluated once, left to right, before anything is copied, so no code the program wrote runs between taking a copy and writing it back, and a call in an index that changes the same field is not undone by the write. Because these are values, a copy of grid or of grid[i] taken before the write still holds what it did.
A let binding at the root, or a field not declared var, is E0114, as the same assignment written out would be. A call's result is held by nothing, so there is nowhere to keep the change, and a tuple's elements are fixed once it is built; both are E0200. Bind the value to a var and change that.
6. Statements and control flow
6.1 if
if x > 10 {
io.print("big")
} else if x > 5 {
io.print("medium")
} else {
io.print("small")
}
Parentheses around the condition are not permitted. Braces are always required. The condition must be bool — there is no truthiness.
if is also an expression when every branch yields a value and an else is present:
let label = if x > 10 { "big" } else { "small" }
6.2 for
for is the only loop keyword. It has three forms.
// 1. Iterate a slice, a range, or a map. (The `Iterate` trait that would
// generalise this to a user type needs associated types; see §10.4.)
for item in items {
io.print(item)
}
for i in 0..10 { } // range, half-open: 0 through 9
for i in 0..=10 { } // inclusive range
for (key, value) in m { } // maps yield tuples
for (i, item) in enumerate(items) { } // and so does a slice of pairs
for (a, b) in zip(xs, ys) { }
// 2. Conditional
for count < 10 {
count = count + 1
}
// 3. Unconditional
for {
if done { break }
}
The three headers above are the whole of iteration: over a sequence, while a condition holds, and forever. Labelled break and continue are supported for nested loops:
outer: for row in grid {
for cell in row {
if cell.empty { continue outer }
}
}
6.3 defer
use std/socket
async fn greeting(url: str) -> (str, error) {
let (sock, err) = await socket.connect(url)
check err
defer socket.close(sock)
// ... any return from here closes the socket
let (first, rerr) = await socket.receive(sock)
check rerr
return first, nil
}
Deferred calls run in reverse order of registration when the enclosing function returns, by any path — check propagating an error included. A defer registers when control reaches it, so one inside a loop registers once per iteration, each with the operands that iteration evaluated, and one in a branch not taken never registers. A closure is a function of its own: a defer in its body runs when the closure returns. Unlike Go, defer cannot modify the return value — it is purely for release of resources, which is the only use that survives scrutiny.
6.4 match
See §9.
7. Error handling
This is the part of Kite that differs most from its influences, so the reasoning is given in full.
7.1 The problem being solved
Go's (T, error) convention is correct in philosophy: errors are ordinary values, every failure point is visible in the source, and there is no invisible unwinding. Its flaws are not in the shape but in the enforcement:
- An error can be silently dropped.
v, _ := f()compiles, and so does
simply never testing err.
- The value is valid-looking on the error path. When
ffails,vis the
zero value — 0, "", nil — and it flows onward indistinguishably from a real result. This is the mechanism behind a large share of production nil dereferences.
- There is no exhaustiveness. Nothing checks that you handled the error at
all.
In 2025 the Go team formally announced they will pursue no further error-handling syntax proposals, closing the door on fixing this within Go. So the shape is worth keeping and the enforcement is worth adding.
7.2 The error type
pub trait Error {
fn message(self) -> str
}
error is a built-in nil-able type — either nil, or a value describing a failure.
Error is declared in the prelude, beside Display and Debug, and any type may implement it. A value whose type does is accepted wherever an error is expected; the conversion happens at that point and is an ordinary call in the IR, so nothing about it is hidden from a reader of the generated code.
An error carries what it was made from. The conversion renders the message where the failure happened — which is where its context is freshest — and keeps the value beside it, with its type, so a caller four layers up can ask which failure this was instead of matching on text:
let (cfg, err) = load("app.toml")
if NotFound.is(err) {
return defaults(), nil
}
let missing = NotFound.as(err) // Option<NotFound>
Each specialisation of a generic type is its own type, and an error carries the one it was made from. So as on a generic type is told which by the type it is used as — let w: Option<Wrapped<int>> = Wrapped.as(err) — and is, which has nowhere to be told, is refused on one, as is an as nothing says the arguments of (E0209).
The type names itself. §11 has no turbofish, so errors.is<T>(err) — which this document used to promise — has nowhere to write its type argument. NotFound.is(err) says the same thing in a place the language can spell, and it is the shape Decode already uses for the same reason (§10.4): User.decode(doc) names the type at the front because a call site cannot name it anywhere else. as is a keyword, and after a . it is a member name — the only position where that is true, and admitted one keyword at a time rather than by opening the position to all twenty-seven.
pub struct NotFound {
pub resource: str
pub id: str
}
impl Error for NotFound {
fn message(self) -> str {
return "\(self.resource) \(self.id) not found"
}
}
7.3 Correlated results and taint analysis
A function returning (T, error) returns a correlated pair. The compiler tracks two flow-sensitive states across the function body:
- The error binding is Unchecked or Checked.
- The value binding is Tainted or Clean.
The rules:
R1. Afterlet (v, e) = f(),eis Unchecked andvis Tainted. R2. Reading a Tainted binding is a compile error (E0301). R3. An Unchecked binding going out of scope is a compile error (E0302). Areturn,check,breakorcontinueis a way out of scope for every binding it leaves behind, on its own path: an error bound aboveif n > 0 { return 1 }has to be checked before thatreturnas well as after it. So is a write over it:var e = f()thene = nil,e = otherore = g()dropsf's failure, and isE0302at the write. R4. On any path where the compiler provese == nil,ebecomes Checked andvbecomes Clean. R5. On any path wheree != nil,ebecomes Checked andvremains Tainted permanently. The value slot on an error path holds no value at all — not a zero value — and cannot be read. R6. A call left as a bare statement, whose type iserroror(T, error), is a compile error (E0302). Binding nothing is not a way out of binding an error. R7. Anerror, or a whole(T, error), bound to a single name makes that binding Unchecked — byletor byvar, whether it came straight from a call or throughawait, a branch of a valueif, or anything else that can hold a new failure. Assigning one to an existing binding,e = f(), does the same. Onlyniland a copy of another binding, which carries its own obligation, leave it Checked. Reading it — testing it, checking it, returning it, taking it apart — inspects it, and R3 applies otherwise. Binding everything under one name is not a way out either.
R1–R5 are about bindings, and R6 and R7 close the shapes they leave open: a call written as a statement makes no binding, so nothing in R1–R5 ever sees it, and dom.set_text(e, "hi") would drop its failure in silence. That is §7.1's first flaw arriving through the one door the analysis did not watch, and it is the ordinary shape on the web, where nearly every std/dom function answers with a bare error.
To drop one deliberately, say so:
_ = dom.set_text(note, "…")
_ already means a hole where a value would go in a return and in a destructuring; this is the same word in the one position that lacked it. What it buys is not safety — the error is just as gone — but that it is gone because somebody decided, on a line a reader can see and grep can find.
The analysis is a standard forward dataflow pass over the control-flow graph, run after type checking. It is not a borrow checker; it has no notion of ownership, aliasing, or lifetimes, and it terminates in a single pass because the lattice has height two.
In practice:
fn title_of(document: str) -> (str, error) {
let (parsed, err) = json.parse(document)
// parsed: Tainted err: Unchecked
if err != nil {
return _, err // parsed is still Tainted here — cannot be used
}
// parsed: Clean err: Checked
return json.text_or(parsed, "title", "untitled"), nil
}
Attempting to skip the check:
fn broken(document: str) -> str {
let (parsed, err) = json.parse(document)
return json.text_or(parsed, "title", "untitled")
}
The return leaves err behind unchecked (R3) and reads parsed while it is still tainted (R2), so there are two errors:
error[E0302]: `err` is not checked before this `return`
┌─ titles.kite:3:5
│
2 │ let (parsed, err) = json.parse(document)
│ --- bound here
3 │ return json.text_or(parsed, "title", "untitled")
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `err` goes out of scope here unchecked
│
= note: silently dropping errors is the single most common source of production failures in languages that permit it
= note: to propagate, write `check` on its own line; to handle it here, test `err != nil`
error[E0301]: `parsed` is used before its error is checked
┌─ titles.kite:3:25
│
2 │ let (parsed, err) = json.parse(document)
│ ------ this value is only valid when the error is nil
3 │ return json.text_or(parsed, "title", "untitled")
│ ^^^^^^ used here while still tainted
│
= note: check it first: write `check err`, or test `err != nil`
= note: in Go the value on a failure path is the zero value and flows onward looking valid; in Kite there is no value on that path at all
7.4 The check keyword
The propagation case — "if this failed, my caller should deal with it" — is the overwhelming majority of error handling in real code. It gets one keyword:
check err
which is defined as exactly:
if err != nil {
return _, err
}
check is only valid inside a function whose last return component is error. _ in a return's value position means no value; it is not a zero value and the correlated pair records the error branch. So the error beside it must be a failure: return _, nil is E0200, and an error that is nil when return _, err runs is a trap at that return.
This is deliberately not Rust's ?. A postfix ? disappears into the middle of an expression and permits nesting failures inside a larger expression. check occupies its own line, is greppable, and preserves Go's central virtue: you can scan the left margin of a function and see every place it can fail.
pub fn load_config(path: str) -> (Config, error) {
let (bytes, err) = fs.read(path)
check err
let (text, err) = str.from_utf8(bytes)
check err
let (cfg, err) = toml.parse(text)
check err
return cfg, nil
}
Rebinding err in the same scope is permitted, and is the one exception to the same-scope shadowing rule in §4.1 — but only because the previous err is provably Checked at that point.
7.5 Handling a failure in place
To handle a failure rather than propagate it, test the error. In the branch where it is nil, the value becomes readable:
let (value, err) = config.get_int("port")
let port = if err != nil { 8080 } else { value }
The branch is written out, on the line where the failure happens, which is what makes every failure path visible in the source.
7.6 Adding context
let (bytes, err) = fs.read(path)
check errors.wrap(err, "loading config from \(path)")
errors.wrap returns nil when given nil, so this composes with check directly — and because it returns nil only when given nil, passing the check proves err nil and makes the value it guards readable (R4). That is known of errors.wrap alone: a function of the program's own may answer nil for anything, so check of what it returned proves nothing about what it was handed. The context goes in front of the message, so a failure that crosses four layers reads as the four sentences that produced it.
It keeps what it wrapped, rather than flattening it into text. err.cause() is the error underneath — an error, not an Option<error>, because error is already the nil-able type and two ways to say absent is one too many. errors.chain(err) is every message outermost first, and errors.root(err) is the innermost: the failure that actually happened, under the context. Which is what makes the downcast in §7.2 useful four layers up:
let (cfg, err) = start()
if err != nil {
io.print(join(errors.chain(err), " <- "))
if NotFound.is(errors.root(err)) {
io.print("nothing was there to load")
}
}
7.7 Unrecoverable failures
Some conditions are not errors — they are bugs. Array index out of range, integer division by zero, an exhausted invariant. These trap: the Wasm unreachable instruction on the web target, abort on native. A trap is not catchable. There is no recover, no panic handler, and no unwinding.
assert(cond, msg) traps when cond is false. It is compiled out in release builds; require(cond, msg) is the always-on variant.
A call chain deeper than the target allows traps too, with call depth exceeded. The bytecode VM and the native target allow 100,000 frames, and agree to the call. A WebAssembly program's frames are its host's stack, which in a browser or Node holds a few thousand frames of an ordinary function — fewer the more values each holds across its call — and running out of it ends the program as a trap, not as the host's RangeError. A recursion whose depth input decides, such as a parser's, should bound it and fail with an error instead, as std/json and std/toml do past 128 levels.
This is a deliberate rejection of Go's panic/recover, which creates a second, invisible error-propagation channel alongside the visible one.
8. Structs and methods
8.1 Declaration
pub struct Rect {
pub width: float
pub height: float
pub var label: str // mutable field
}
Struct values are GC-managed references. Assignment copies the reference, not the contents. Because fields are immutable unless marked var, this is indistinguishable from value semantics for the majority of types, without the copying cost or the pointer/value receiver distinction.
8.2 Methods
impl Rect {
pub fn area(self) -> float {
return self.width * self.height
}
pub fn scaled(self, factor: float) -> Rect {
return Rect{ ..self, width: self.width * factor, height: self.height * factor }
}
pub fn rename(var self, name: str) {
self.label = name // permitted: `var self` and `label` is `var`
}
// Associated function — no self
pub fn square(side: float) -> Rect {
return Rect{ width: side, height: side, label: "" }
}
}
self is immutable unless the method declares var self. A method with var self cannot be called on a binding the caller does not own mutably.
Rect.square(2.0) calls the associated function; r.area() calls the method.
Multiple impl blocks for the same type are permitted within a module, and a type has one method of each name across all of them (E0112). A type's inherent methods must be declared in the module that declares the type — there are no extension methods, so x.foo() can always be resolved by looking at where x's type is defined. An impl block for another module's type is E0406, and one for anything but a struct or an enum — a trait, or an alias of int — is E0204.
A method may declare type parameters of its own, after its block's:
impl<T> Box<T> {
pub fn map<U>(self, f: fn(T) -> U) -> Box<U> {
return Box{ value: f(self.value) }
}
}
The block's parameters come from the receiver's type and the method's own from its arguments, as a generic function's do. Self inside an impl block is the type the block is for, in its body as in its signatures, and wherever a body writes the type's name: a literal Self{ n: 1 }, a pattern, Self.make(3), Self.Off. In a trait's default method it is whichever type implements the trait, known only by the trait's methods, so it names no literal or path there.
An impl block is for every instantiation of a generic type at once: its header names the type at the block's own parameters, in order — impl<A, B> Pair<A, B>, impl<T: Show> Display for Box<T>. A header for one instantiation, impl Display for Pair<int, str>, or with the parameters reordered, is E0208; a bound on a parameter is how a block says which instantiations it covers. A header that leaves the arguments out stands for the type at the block's own parameters, so impl<A, B> Display for Pair is the same block, and one that declares none or too few for them, impl Display for Pair, is E0208 too.
9. Enums and pattern matching
9.1 Enums
pub enum Shape {
Circle(radius: float)
Rect(width: float, height: float)
Point
}
pub enum Json {
Null
Bool(bool)
Number(float)
Text(str)
Array([Json])
Object({str: Json})
}
Variants may carry named or positional payloads. Enums are recursive by default — Json above needs no boxing annotation, because every Kite aggregate is already a GC reference.
9.2 match
let description = match shape {
Circle(radius) => "circle of radius \(radius)",
Rect(w, h) if w == h => "square of side \(w)",
Rect(w, h) => "rect \(w)x\(h)",
Point => "a point",
}
match is exhaustive. Omitting a variant is a compile error that names the missing variants:
error[E0210]: non-exhaustive match: `Rect(_, _)`, `Point` not covered
┌─ shapes.kite:4:19
│
4 │ let d = match shape {
│ ^^^^^ this value is not fully matched
│
= note: exhaustiveness is what makes adding a variant safe: the compiler shows you every place that must change
Exhaustiveness is what makes adding an enum variant safe: the compiler shows you every place that must change.
Coverage is decided through nested patterns, not just the outermost one: On(true), On(false) and Off cover an enum Light { On(bool) Off }, (true, _) and (false, _) cover a (bool, int), and nil, A and B cover an Option<E> — a pattern written against an optional is one for the value inside it, present, whether a literal, a tuple, a struct or a variant of a generic enum. A missing case is named however deep it is: Add(Num(_), _). Numbers and strings have no finite set of values, so a match on one needs a catch-all. A guarded arm counts towards nothing, since its guard may fail.
An arm no value can reach — everything it matches is taken by an unguarded arm above it — is a warning (E0116). The usual cause is a name meant as a variant that is not one: Dir where the enum says Directory is a binding, which takes every value.
An arm is an expression or a block. A block arm runs for its effects: it produces a value only when it is a single expression, because there are no tail expressions in Kite — a value arriving by falling off the end of a block is the hidden control flow the language is spent avoiding, and an if used as a value says "expected a single expression" for the same reason.
So an arm that needs several statements and a result returns from the function instead, with the match in statement position:
fn area(s: Shape) -> int {
match s {
Circle(r) => {
let d = r * 2
return 3 * d * d / 4
},
Rect(w, h) => {
return w * h
},
Point => {
return 0
},
}
}
Every arm leaving means the match leaves, so this is a returning path and the function needs no return after it.
9.3 Patterns
match value {
0 => "zero", // literal
1 | 2 | 3 => "small", // alternation
4..=9 => "medium", // range
n if n < 0 => "negative", // guard
_ => "large", // wildcard
}
match point {
Point{ x: 0.0, y: 0.0 } => "origin", // struct pattern
Point{ x: 0.0, y } => "on y axis at \(y)",
Point{ x, y } => "at \(x),\(y)",
}
match pair {
(nil, nil) => "neither",
(a, nil) => "first only",
(nil, b) => "second only",
(a, b) => "both",
}
Bindings introduced by patterns are immutable. There is no ref or mut in patterns because there are no references to bind.
The alternatives of an alternation may bind names, and then each must bind the same names with the same types (E0200): the arm runs whichever one matched, and reads each name as that one bound it. Circle(r) | Square(r) => r * r binds one r, not two.
A variant pattern may be written bare, Circle(r), or qualified, Shape.Circle(r), and the two name the same variant; another module's is qualified by its module as well, shapes.Shape.Circle(r) (§13.1). A bare unit pattern is looked up among the scrutinee's variants, so two enums may each declare Slow. A bare pattern with a payload is looked up by name, as a bare constructor is, so once two enums in scope declare Circle, Circle(r) is E0111 and is written Shape.Circle(r).
10. Traits
10.1 Declaration and implementation
pub trait Display {
fn show(self) -> str
}
pub trait Comparable {
// Negative, zero or positive, as `self` sorts before, with or after `other`.
fn compare(self, other: Self) -> int
// Default methods
fn less_than(self, other: Self) -> bool {
return self.compare(other) < 0
}
}
impl Display for Rect {
fn show(self) -> str {
return "Rect(\(self.width) x \(self.height))"
}
}
Trait implementation is explicit and nominal, unlike Go's structural interfaces. The reasoning: structural satisfaction produces error messages that name the missing method but cannot name the intent, and it makes accidental satisfaction possible. impl Display for Rect is a statement the author made on purpose, and the compiler can say "Rect does not implement Display" with a precise place to point at.
Self inside a trait refers to the implementing type. An implementation is checked against the trait with Self read as its own type, so Rect writes fn compare(self, other: Rect) -> int or, equally, other: Self. It must also agree about the receiver: a method the trait declares with self may not take var self, and the reverse, because a call through the trait — a bound or a dyn — sees only the trait's. For the same reason it must agree about whether the method can fail and whether it is async: a call through the trait yields what the declaration says, a (T, error) pair or a Task, as a direct call does. A generic method's type parameters may be bounded no more tightly than the trait's are (E0208): a caller through the trait meets the trait's bounds and no others.
A default method's body is checked for each implementation that takes it, with Self read as that implementation's type, and a mistake in it is reported once, not once per implementation. A default no implementation takes — the trait has none yet, or each writes its own — is checked all the same, with Self standing for any implementation: what the trait declares is known of it, and nothing else.
10.2 Coherence
A trait implementation is permitted only in the module that declares the trait or the module that declares the type (E0406). This is the orphan rule, and it guarantees that a given (trait, type) pair has exactly one implementation program-wide, which is what makes trait resolution decidable and separate compilation possible. A trait of your own may therefore be implemented for an imported type, and an imported trait for a type of your own; implementing an imported trait for an imported type is the one thing refused.
10.3 Static and dynamic dispatch
// Static — monomorphised at compile time, zero-cost, no indirection
fn render<T: Display>(item: T) {
io.print(item.show())
}
// Dynamic — one machine-code copy, indirect call through a vtable
fn render_all(items: [dyn Display]) {
for item in items {
io.print(item.show())
}
}
dyn Trait is required to be explicit. A heterogeneous collection needs dyn; a generic function does not. On the Wasm target, dyn Trait lowers to a WasmGC struct holding the data reference plus a vtable of typed function references (from the typed-function-references feature ratified in Wasm 3.0), so the indirect call is type-checked by the engine rather than through a signature table.
Not every trait can be made dyn. A trait is object-safe when every method takes self, no method mentions Self otherwise, and no method is generic — a dyn holds some type the call cannot know, so each method must mean the same thing whichever it is (E0206). Non-object-safe traits can still be used as generic bounds, where the type is known and every method is an ordinary call; the error message says which method is responsible.
10.4 Built-in traits
Three of these the compiler applies on its own; the rest are asked for, and one is refused. The distinction is not arbitrary — it is whether a mechanical answer is the right answer.
| Trait | Meaning | How it arrives |
|---|---|---|
Eq | == and != | Structural, on every type, always |
Share | Safe to move across tasks | Inferred structurally — see §12.3 |
Display | String interpolation, io.print | Written by hand, never derived |
Debug | A rendering for a programmer | @derive(Debug) |
Hash | One integer standing for a value | @derive(Hash) |
Encode / Decode | To and from json.Json | @derive(Encode, Decode) |
Ord | < <= > >= | Not a trait — see below |
Iterate | for x in … | Not written — see below |
Eq is not a trait. == is structural on every Kite value: a struct compares its fields, an enum its tag and then its payload, a slice its length and then its elements. There is nothing to implement, nothing to derive, and no type that lacks it — so a trait for it would be a second spelling for what the language already does, and a second spelling is a chance for two answers.
Display is deliberately not derived. How a type presents itself to a human is a design decision, not a mechanical one, and a Password whose derived form printed its field is the case where being wrong matters.
Ord is not a trait either. < on aggregates is not defined: what order two structs are in is a decision with several defensible answers, and the language declines to pick one. Sorting takes the comparison as an argument — sorted(people, |a, b| a.age < b.age) — which is where the decision belongs.
@derive
@derive(…) writes a body from a type's fields. It is one of the two attributes Kite has, and the bar an attribute must clear is that it names something the compiler must do which no amount of Kite could say instead.
@derive(Debug, Hash, Encode, Decode)
pub struct User {
name: str
age: int
tags: [str]
}
What it produces is ordinary Kite, expanded before resolution: it is checked, lowered and optimised like anything hand-written, kitec --emit hir shows what actually ran, and a derived method is not privileged over a written one. Deriving something a type already implements by hand is an error rather than a silent replacement.
The walk handles primitives, slices, maps, optionals, tuples, and other types that derive the same trait. Where it cannot go — a function field, a dyn Trait, a type parameter — it says which field stopped it and what would fix it, and the hand-written implementation is still there to write.
Decode is an associated function, not a trait method: it produces the implementing type, and a trait method cannot say that without Self in return position. So a document becomes a value by naming the type, which is what a caller has anyway:
let (doc, err) = json.parse(text)
check err
let (user, uerr) = User.decode(doc)
check uerr
There is no json.decode<T>(text). Kite has no turbofish, so the type would have to be inferred from the binding, and User.decode says the same thing where it can be read.
Iterate cannot be written in this language yet, and the implementation says so rather than pretending. A trait that yields values needs to name the type it yields, which is an associated type — and §11 excludes associated types from version 1.0 on the grounds that they cost error-message quality. The two decisions are in tension, and the tension is resolved for now in favour of the simpler type system: for x in … works over ranges, slices and maps, all three of which the compiler knows the element type of directly. A user type becomes iterable by exposing a slice.
Whichever way this is settled later, it is a real change: adding associated types is a type-system change, and special-casing Iterate in the compiler is a language with one magic trait in it.
11. Generics
pub fn map<T, U>(items: [T], f: fn(T) -> U) -> [U] {
var out: [U] = []
for item in items {
out.push(f(item))
}
return out
}
pub struct Cache<K: Hash, V> {
var entries: {K: V}
capacity: int
}
impl<K: Hash, V> Cache<K, V> {
pub fn get(self, key: K) -> Option<V> {
return self.entries[key]
}
}
Generics are monomorphised: each distinct instantiation produces its own specialised code. This gives static dispatch and full inlining, at the cost of binary size when a generic function is instantiated at many types.
Because binary size is a first-order concern on the web, that cost is measured rather than hidden: a WebAssembly build reports the module's size, and CI holds a set of programs to a size budget. Identical-code-folding — merging instantiations whose generated bodies are byte-identical, as [User] and [Post] often are when every operation is a reference move — is not built yet (docs/03 §6), and there is no per-function size warning. Where a generic function is instantiated at many types, dyn is the remedy.
A generic function that calls itself at a larger type — depth([x], n - 1) inside depth<T> — or a generic type that holds itself at one needs a copy per level without end, and is E0220. So, in its own words, is a program that finishes but asks for more than the compiler makes: a type argument nested more than 256 levels deep or holding more than 65,536 parts, or more than 65,536 specialisations in all.
Bounds are trait names, and a parameter satisfies one by implementing it. That is the whole of the system: a generic function is a function whose parameter types are named rather than fixed, and monomorphisation makes each use an ordinary call.
A bound holds wherever it is written. On a function, it is checked at every call (E0208). On a struct or an enum, it is checked wherever a value is built — a Cache<fn(), int> cannot come into existence — and on an impl block, at every call of its methods and wherever the type is used as the trait the block implements. A generic type implements a trait only where its arguments meet that block's bounds. Inside a generic function a parameter is known only by its bounds, and it satisfies exactly those: fn outer<T: Show>(x: T) may pass x to fn inner<T: Show>, and an unbounded T may not.
12. Concurrency
12.1 The model
pub async fn fetch_user(id: UserId) -> (User, error) {
let (res, err) = await http.get("/api/users/\(id)")
check err
let (doc, err) = json.parse(res.body)
check err
let (user, uerr) = User.decode(doc)
check uerr
return user, nil
}
An async fn returns a Task<T>. await suspends until it completes. A method or an associated function may be async too, and calling one yields its Task in the same way: await conn.fetch().
Calling an async fn does not run its body. It yields the Task and returns; the body runs when something drives it, which is await. Two calls made and then awaited together overlap — that is how concurrency is expressed — but between the call and the await nothing has happened at all. A guard written at the top of an async fn has therefore not run when the next line of the caller executes, which matters for anything that must not be started twice:
// Sequential — 200ms total
let (a, err) = await fetch_user(1)
check err
let (b, err) = await fetch_user(2)
check err
// Concurrent — 100ms total
let ta = fetch_user(1)
let tb = fetch_user(2)
let (ra, rb) = await task.both(ta, tb)
let (a, ea) = ra
check ea
let (b, eb) = rb
check eb
task.all([...]), task.race([...]), and task.timeout(t, ms) cover the remaining combinators. There is no channel type; a Task<T> is the one-shot result channel, and it is awaited rather than received from.
12.2 Parallelism: the surface is thread-agnostic
async says nothing about how many threads exist. That is a property of the runtime, and the surface does not change when the runtime gains one:
| Target | Scheduler | Real parallelism |
|---|---|---|
wasm32-gc (web) | Cooperative loop on the main thread | No |
kbc (bytecode VM) | Cooperative loop; the VM's values are Rc-based | No |
native-* | Cooperative loop | No |
| any, later | Work-stealing pool, one worker per core | When the runtime has one, with no source change |
Nothing here runs on two cores today, and this table used to claim two of them did. That was the specification describing the intended runtime rather than the one that exists: there is no thread spawned anywhere in the compiler or the runtime, and task.parallel walks its input in order. The rest of this section is about why the type system is finished even though the scheduler is not, and that part is true.
The web restriction is not a design choice. WasmGC references cannot currently cross a thread boundary at all — there is no way to share a reference value between Wasm threads. The shared-everything-threads proposal exists precisely to fix this and is still a draft. This is why Kotlin/Wasm's Dispatchers.Default and Dispatchers.IO silently execute on the main thread, and why Flutter's multi-threaded web rendering requires COOP/COEP headers and still cannot share its object graph.
The point of specifying Share now is that Kite programs become parallel on the web the day that proposal ships, without a source change. The type system already enforces the invariant the proposal will require. This is the single most important forward-compatibility decision in the language.
task.parallel is the shape that work will arrive in, and it is not parallel on any target today — it applies the function to each item in turn, yielding between them. What is real about it now is the rule: its argument and its result must be Share, so the day a reference can cross a thread boundary, the same source starts using cores and nothing about it is rewritten. That is the whole point of specifying the marker before the platform can honour it:
let results = await task.parallel(chunks, |chunk| {
return heavy_transform(chunk) // chunk and result must be Share
})
12.3 The Share marker
Share is an auto-derived marker trait meaning "a value of this type may be moved to another thread or isolate."
A type is Share when:
- it is a primitive, or
- it is a
str, or - it is a struct or enum **all of whose fields are
Shareand none of which is
var**, or
- it is a slice, map, or tuple of
Shareelements, or - it is explicitly wrapped:
sync.Mutex<T>orsync.Atomic.
A type is not Share when it has a var field anywhere in its transitive structure, or when it holds a JsValue — a DOM node, a canvas context, a file handle (§15.1). A host reference belongs to the isolate that created it, and an integer standing in for one would carry none of that: it would satisfy every rule above and mean nothing on the other side. A lock does not change that — it settles races, not isolates — so a sync.Mutex holding a JsValue is not Share either. Nor is a function or a dyn Trait, whose contents are not known where the type is.
A function handed to a Share-bounded parameter — task.parallel's mapper — goes to the other task with everything it captured, so each capture must be Share too; a closure holding a Counter with a var field, or a JsValue, is refused where it is written. So is a function value whose closure is out of sight there, such as one read from a binding. A type parameter bounded by Share is Share, and may be passed on to another such bound.
Because struct fields are immutable by default, most user types are Share without the author doing anything or knowing the trait exists. The marker only becomes visible when it is violated:
error[E0520]: `Counter` cannot be moved to another task
┌─ worker.kite:12:31
│
12 │ await task.parallel(items, |c| c.tick())
│ ^ `Counter` is not Share
│
┌─ counter.kite:2:5
│
2 │ var count: int
│ --- because this field is mutable, `Counter` may not be shared
│
help: two values of a mutable type in two threads is a data race. Either
make `count` immutable and return a new Counter, or wrap the type
in `sync.Mutex<Counter>` to serialise access.
Kite therefore has no data races by construction, on every target, with no annotation burden in the common case. This is the same insight as Rust's Send and Swift 6's Sendable, made nearly invisible by choosing immutability as the default.
12.4 Implementation
async fn compiles to a state machine: the function body is split at each await into a resumable coroutine object, with locals that live across a suspension point stored in a WasmGC struct. This is the same transformation Rust, C#, and Kotlin use, and it requires no Wasm features beyond those ratified in 3.0.
The stack-switching proposal would permit a cheaper implementation using real coroutine stacks. It is post-3.0 and not yet shipped. Kite's semantics are compatible with either lowering, so adopting it later is a compiler change with no language change.
13. Modules and packages
13.1 Structure
A module is a directory, or a single .kite file. Every .kite file in a module directory contributes to the same namespace — within one there are no per-file imports of siblings and no header/implementation split.
use x takes the directory x/ when there is one and the file x.kite otherwise. The directory is the general form; a file is the one-file case, and is what most modules are and what nearly all of them start as.
myapp/
kite.toml
src/
main.kite
money.kite // a module, one file
config/
load.kite
schema.kite // same module as load.kite
ui/
app.kite
theme.kite
use config
use money
use ui
use std/http
use std/json as j
fn main() {
let err = start()
if err != nil {
io.print("cannot start: \(err.message())")
}
}
fn start() -> error {
let (cfg, err) = config.load("app.toml")
check err
ui.run(ui.App{ config: cfg })
return nil
}
Neither form is a module until a use names it. A .kite file nothing imports is not compiled, and two files sharing a directory share nothing by virtue of sitting there: main.kite above cannot see what money.kite declares without the use money.
Imports are always qualified by module name at the use site. There is no wildcard import and no way to bring a bare name into scope. config.load always tells you where load came from. That holds for enum variants too: a bare Circle is a variant of one of the module's own enums, or of the prelude's, and another module's is written shapes.Shape.Circle.
And a module reaches only what it imports. Writing config.load in a file whose module has no use config is an error even when another module in the program does import it — the entry file included. Declarations are merged under their qualified names, so without this rule config.load would exist for the whole program the moment anybody loaded config — whether a name resolved in one file would depend on a use line in a file that had nothing to do with it, and a dependency could reach the importing program's own modules by naming them.
An alias belongs to the module that writes it. use std/json as j makes j mean json in that module and nowhere else. Aliases used to share one program-wide table, so use leak as crypto written anywhere — including inside a dependency — rewrote every crypto.… call in every other module, silently and with no diagnostic. An alias is a convenience for the file that writes it.
A module is where its source is. Two use lines reach one module exactly when they reach one file or directory: use utils inside a/ and use utils inside b/ are two modules, and use dep/utils and use utils are two modules, because every segment is honoured when the files are found. A use that finds nothing is E0400 — never answered by some other module that happens to be spelled alike. A first segment naming one of the importing package's declared dependencies roots there instead, so use markdown/render reaches inside the package.
What a use site writes is a spelling, and by default it is the last segment. A spelling belongs to the module that writes it, so two modules may spell different modules the same way; what one module may not do is spell two modules the same way:
use utils // `utils.…` is this one
use dep/utils as theirs // `theirs.…` is that one
Two rules, both errors rather than a silent choice:
- The standard library's names are its own. A non-
stdmodule may not take
one (E0403). The reserved names are buffer, canvas, crypto, dom, draw, errors, fmt, fs, html, http, io, js, json, math, prelude, ptr, socket, sync, task, test, text, time, toml and window — its modules, the modules its builtins are reached through, and the prelude. The check is on the last segment of the path, alias or not, so use dep/crypto is refused as a sibling crypto is: a module named crypto is spelled crypto by default, and would shadow the standard library in the file that imported it. The name after as is a spelling too, and may not be a reserved name either unless it is the std module's own: use util as errors would make errors.new the standard library's and every other errors.… this module's.
- One module may not spell two modules alike (
E0404). The files of a
directory module share their imports, so use utils followed by use dep/utils — in one file or in two — is refused, because every utils.… would quietly change meaning. Give one of them an alias.
13.2 Manifest
[package]
name = "myapp"
version = "0.1.0"
[targets]
web = { entry = "src/main.kite", renderer = "dom" }
native = { entry = "src/main.kite" }
[dependencies]
markdown = { git = "https://github.com/example/kite-markdown", tag = "v1.2.0" }
A package's dependencies are its own. A module inside a package resolves use against that package's manifest — the one in its own directory, never one above it — so a package uses what it declares and nothing the program declared for itself. A path is relative to the manifest that writes it; a git dependency is read from the program's .kite/vendor, where kitec pkg puts every package in the graph. A name — package or dependency — is an identifier, because it is written in a use. A key the manifest does not define is an error rather than something ignored, and a manifest that does not parse — or does not read at all, as one that is not UTF-8 — is E0405 in every command that reads it.
A package name means one thing across the whole program: two manifests naming one package from two places is an error. It does not take the name from the program's own modules, though. A package only a dependency declares may share its name with one of the program's files — the program's use log is its own log.kite, and the dependency's use log is the package it declared — because a module is where its source is.
Dependencies are resolved to a lockfile of SHA-256 content hashes, and the lockfile is checked, not just written: a dependency whose contents changed under the same version and source — a moved tag, a re-pushed repository — makes kitec pkg fail rather than quietly recording the new bytes. --update accepts a change, which is a decision someone makes rather than something a build does on its way past, and fetches every checkout again to make it — beside the one it replaces, so a fetch that fails leaves that one in place. Anything else that changed — a dependency added or removed, a new version because the manifest now asks for one — is reported rather than refused, and resolution tries the versions the lockfile records before any newer one.
The digest is cryptographic because the party it is checked against is the one who chooses the bytes. It was FNV-1a, which is invertible, so a dependency's author could have made any change land on the recorded hash.
It covers every .kite file in the package a use could reach, node_modules/ included — a directory whose name is an identifier is one a use can name. .git and .kite/ are left out, and no use can name either. A symbolic link a build would follow — a .kite file, or a directory a use can name — is refused rather than hashed, since what it leads to is not the package's.
kitec pkg is where that check happens, and it is the only place: kitec build, run and test compile whatever is in .kite/vendor without consulting kite.lock. A pipeline that wants the guarantee has to run kitec pkg in it.
Dependency URLs are fetched over https:// or ssh:// only. http:// and git:// authenticate neither the host nor the bytes, and what is fetched is compiled and run — and because a transitive manifest can name a URL, a project could otherwise be opted into cleartext by a dependency it never chose.
There is no post-install script mechanism, no transitive-dependency hoisting, and no way for a dependency to execute code at build time — the supply-chain attack surface that has repeatedly compromised npm is absent by construction rather than by policy.
13.3 Cycles
Module cycles are an error. Cyclic dependencies make separate compilation, incremental rebuilds, and initialisation order all harder, and every cycle can be broken by extracting the shared part.
14. Memory model
Kite's memory is managed on every target. There is no manual allocation, no free, no ownership, no borrowing, and no lifetimes.
| Target | Collector |
|---|---|
wasm32-gc | The host engine's collector. WasmGC objects are allocated with struct.new / array.new and traced by V8, SpiderMonkey, or JavaScriptCore directly. Kite ships no collector in the binary. |
native-* | Precise tracing collector, generational. New objects are bump-allocated in a nursery, and a minor collection moves the survivors into the old generation, updating every reference to them; the old generation does not move, and is collected by mark-and-sweep. Stack maps emitted by the compiler give exact root and field information. |
kbc | Reference counting, not a tracing collector. The bytecode VM is the development loop, the embedding target and the differential-testing oracle, and a value is freed when its last reference goes. A cycle of references — two structs whose var fields point at each other — is never freed while the program runs. That is a leak in a long-running embedding and harmless in a test run; programs meant to run for a long time with cyclic data belong on the native or Wasm target. |
Delegating collection to the browser engine on the web target is the single largest binary-size win available in 2026, and it is why this design was not viable before WasmGC reached cross-browser baseline in Safari 18.2.
Known consequences of WasmGC's current shape, accepted deliberately:
- No interior pointers. A reference always points to the head of an object.
Kite has no &x.field, so this is unobservable.
- No unboxed aggregates inside arrays.
[Point]is an array of references to
Point objects, not a flat buffer of (f64, f64). For numeric work where the layout matters, buffer.F64 provides a flat typed buffer, which is the escape hatch for anything holding a great many numbers — a simulation, a signal, a mesh. It is a [float] with the record's shape written down beside it, so WasmGC stores the numbers themselves in one f64 array. It is not over linear memory, which the Wasm backend does not have.
- No weak references or finalizers. A
Cachethat must not retain its
entries uses an explicit eviction policy rather than weak keys.
14.1 Exclusivity
Collection settles memory safety. It does not settle the one hazard that reference semantics introduce on their own: the same object arriving at a function twice, under two names, where writing through one is invisible to the other.
fn transfer(var from: Account, var to: Account, amount: int) {
from.balance = from.balance - amount
to.balance = to.balance + amount
}
transfer(a, a, 50) // rejected: E0800
Written this way the balance is set to 50 and then back to 100. Nothing traps and nothing is unsafe — the memory is real on both lines — and the program is silently wrong.
The rule: while an object is being written through one argument, no other argument of the same call may name it. Two arguments name the same object when one path is a prefix of the other, so f(o, o.inner) is rejected alongside f(a, a); f(o.left, o.right) is not, because neither path contains the other. A literal index distinguishes elements, so f(xs[0], xs[1]) is accepted and f(xs[i], xs[j]) is not — the compiler cannot show that i and j differ, and the call is wrong on the run where they do not.
Only reference types participate — a struct or a dyn Trait. Slices, maps and tuples are copy-on-write values, so a var [T] parameter is the callee's own copy and two of them cannot interfere.
This is not borrowing. There is no ownership, no move, no lifetime, and nothing to annotate. A borrow checker exists to replace a collector, which is what forces it to reason about every reference in the program and to be complete enough that a rejected program has a rewrite. Kite collects, so this rule is free to be incomplete: it reads one call site, and it reports only what is written there.
The consequence, stated plainly: aliasing arranged elsewhere is not detected.
let shared = Account{ balance: 100 }
let pair = Pair{ left: shared, right: shared }
transfer(pair.left, pair.right, 50) // accepted — the same bug
Seeing through that assignment is alias analysis, and alias analysis is the rest of a borrow checker. The bug it leaves behind is a wrong number, not a wrong address, and the collector guarantees it stays that way.
Two rules a Rust programmer would expect are absent because Kite's value semantics already settle them. A for x in xs loop walks a snapshot, so growing xs in the body terminates and is defined; and a slice passed to a function is copied, so a push inside is not something the caller can observe.
15. Foreign function interface
The web target has no direct DOM access — no Wasm proposal for calling Web IDL without JavaScript glue has been standardised, and none is imminent. Kite therefore defines the boundary explicitly rather than pretending it is not there.
15.1 JsValue
pub struct Element {
raw: JsValue // unmarked: opaque outside this module
}
JsValue is a host reference. On the web it lowers to externref; on every other target it names a diagnostic rather than a value, because there is nothing for it to refer to.
| Property | Reason |
|---|---|
| Opaque | Kite cannot read inside it. It is the host's object, not a Kite one. |
Not Share | It belongs to one isolate (§12.3). |
Not comparable with == | externref is outside Wasm's eq hierarchy, so there is no structural answer to give. Identity is js.same(a, b), which is ===. Writing == on one is a compile error rather than a quiet wrong answer. |
| Cannot be forged | There is no literal for it. |
Lifetime needs no rule. On the web the Wasm heap is the JavaScript heap, so a Kite struct holding an element — whose listener holds a Kite closure, which holds the struct — is a cycle across the boundary that the one collector collects. There is no ownership protocol, no release call, and no table of integers to keep in step. This is the whole argument for a reference over a handle, and it is not recoverable by any amount of care with integers: nothing can tell the host that Kite dropped a number, and WasmGC has no finalizers.
15.2 Two mechanisms, and which is which
extern declares one named function.
@host("net")
extern fn connect(host: str, port: int) -> JsValue
Direct, monomorphic, and checked at the call. It is how std/fs, std/http, std/socket and std/crypto are built, and how the standard library reaches anything it calls often enough for a name lookup to matter. Drawing does not use it at all: the drawing calls are compiler builtins, so a program that paints needs no extern.
A runtime that answers a host function itself — the bytecode VM and the native runtime both answer @host("fs") — holds the declaration to what it reads and returns. A parameter declared as other than what the host reads, or a result declared as other than what it answers, is a trap before the call is made, in the same words on both.
std/js declares nothing. It is a fixed set of about thirty primitives through which any host object can be reached:
js.global(name) / js.nothing() | the root — window, document, a constructor — and null |
js.get(v, name) / js.set(v, name, x) | properties |
js.at(v, i) / js.length(v) | an array-like thing |
js.call0(v, name) … js.call4(v, name, a, b, c, d) | methods, by arity |
js.new0(name) … js.new3(name, a, b, c) | construction, by arity |
js.func(f) | a Kite closure the host can call — up to four JsValue parameters, answering with a JsValue or with nothing |
js.settle(p, done, failed) | both halves of a promise |
js.same(a, b) / js.is_nothing(v) / js.kind_of(v) / js.instance_of(v, name) | identity and kind |
of_str of_num of_bool of_int / as_str as_num as_bool as_int | conversion, both ways |
str_or num_or bool_or | a property with a value to fall back on |
Everything else — std/dom, and any browser API a program needs — is ordinary Kite written over these.
One function per arity, and no js.await. A slice is a Kite aggregate and does not cross the boundary, so call and new are spelled out rather than taking an argument list. And a promise arrives through js.settle, which requires a handler for the rejection as well as the result: then with one callback compiles, runs, and throws a rejection away, which is the failure this language spends its error design preventing everywhere else.
A handler keeps its own shape. js.func takes a closure of up to four JsValue parameters answering with a JsValue or with nothing, and the compiler emits a wrapper of that exact arity:
js.func(|| { … }) // a timer, a microtask
js.func(|e: JsValue| { … }) // a listener
js.func(|entries: JsValue, obs: JsValue| { … }) // an observer
js.func(|a: JsValue, b: JsValue| -> JsValue { … }) // a comparator
The result is what makes sort, map, filter and a Promise executor reachable. Without it, every host API that reads a value back out of a callback was out of reach, and the way round it was to write that part in JavaScript — the one thing this layer exists to make unnecessary. The same four-argument ceiling applies and for the same reason, and a fifth parameter is a compile error rather than an argument that quietly disappears.
Why the general mechanism is the primary one. The browser has thousands of interfaces. With extern alone, the first one the standard library never covered forces a user to hand-write a JavaScript host object and register it with provide. That user is now writing and shipping JavaScript, which is the thing Kite exists to replace. A language whose extension mechanism is "go and write the other language" has conceded its own argument on the first day of real use. The primitives close that: they are the last JavaScript anyone writes, and the generated glue is a fixed size no matter how much of the platform a program touches.
The cost is a name looked up when the program runs rather than fixed when it compiles, and it is paid twice: a small amount of speed, and a mistyped name that compiles. §15.4 is what makes the second one survivable.
15.3 Everything catches
A host exception must never cross the boundary raw. Every primitive that can fail returns a value and an error:
let (node, err) = js.call1(document, "querySelector", js.of_str("#form"))
check err
The taint analysis (§7.3) then makes the check mandatory. This is not defensive style; it is the difference between a mistyped method name failing one call and a thrown exception unwinding through the Wasm frames and taking every running task with it.
It also removes JavaScript's most common class of bug by construction. Reading a property that is not there yields undefined, and undefined becoming 0 or NaN somewhere later is untraceable. as_num returns an error, and the error must be tested before the number can be used.
Numbers cross as f64. JavaScript numbers are f64, and an int is an i64, so every crossing would otherwise allocate a BigInt. The safe-integer check happens on the Kite side, where the failure is a value.
Absence is Option. A host call that may find nothing returns Option<T>. There is no tolerated zero handle and no null object anywhere in the boundary — a convention that returns something usable-looking for "not found" is the zero zero value §5.3 exists to prevent, wearing a different hat.
15.4 The hygiene boundary
JsValue is untyped. If it reaches application code, the type system has stopped helping and Kite is JavaScript with more syntax. Two rules keep it in:
Wrap it in an opaque struct. A pub struct with unmarked fields (§4.3) can be held and passed but not read, built or destructured. So Element outside std/dom is a real closed type, and no ordinary code can reach the value inside it.
Provide exactly one door out. dom.raw(e) and dom.wrap(v), greppable and documented. Sealing a wrapper completely sounds safer and is not: the user who needs one method the library never wrapped cannot reach their own element, and what they do instead is rebuild a parallel untyped world beside the typed one. One marked escape is a boundary that holds; a wall is a boundary that gets climbed.
std/js is a separate module so that importing it is visible in a file's first three lines. It is the floor below the typed world, and it carries the same cultural marking Rust gives unsafe — normal inside a module whose job is wrapping, a smell in an application's public interface.
15.5 What is admitted
Two things are true about this design and are recorded rather than defended.
A mistyped name compiles. extern did not have this problem. Three things reduce it: the typed layer is written once and covered by tests, so a typo lives in one place; users call the typed function and never write the string; and the long tail can be generated from the browser's own interface definitions, where the names come from the specification and cannot be mistyped at all. That generator is a build step, and a build step is where code generation belongs. It is not the first step: the definitions carry overloads, which Kite has no way to express, and unions, which each need a decision.
This is reflection over the host. Not over Kite — no Kite metadata is retained, so dead-code elimination stays sound and the reason Kite has no reflection over its own values is untouched. But the spirit of "no second language inside the language" is genuinely strained at the raw layer, where a typo is a runtime mystery rather than a diagnostic. The honest resolution is the one above: a bounded dynamic floor, marked, fenced at public boundaries, with generation as the long-term exit.
16. Diagnostics
Error message quality is a language design constraint in Kite, not a post-implementation concern. Several decisions in this specification — nominal traits over structural, explicit dyn, no implicit conversions, no overloading — were made because they let the compiler produce a message that names one cause and one fix.
Every diagnostic carries a stable code (E0301), a primary span, secondary spans explaining why, and where possible a machine-applicable fix.
error[E0114]: cannot assign to immutable binding `total`
┌─ cart.kite:14:5
│
9 │ let total = 0
│ ----- declared immutable here
⋮
14 │ total = total + item.price
│ ^^^^^ cannot assign
│
help: make the binding mutable
│
9 │ var total = 0
│ ~~~
Requirements on the implementation:
- One error per cause. A single missing brace must not produce forty errors.
The parser recovers at statement and declaration boundaries.
- Type errors name the source of the expectation, not just the mismatch —
the parameter or return type that created the constraint gets a secondary span.
--explain E0301prints the full rationale for the rule.- One cause, one code, however it is reached. Visibility is
E0401for a
function, a type, a field or a method alike; an impl in the wrong module is E0406, whether it adds methods or implements a trait.
kitec fixapplies every machine-applicable suggestion.- A name section and source map are emitted for the Wasm target so browser
stack traces name .kite files and lines. The name section is what gives a frame its name; the map, written beside the module as app.wasm.map and named by a sourceMappingURL section, is what gives it a file and a line. The granularity is one entry per function — a frame resolves to the line the function was declared on, not the line that trapped, because that is the granularity the compiler's own information exists at. Both are dropped by --release: they are more than half of a hello world, and debug information is not semantics.
Appendix A — A complete program
This compiles. crates/kite-driver/tests/spec.rs extracts it from this document and checks it on every run, which is the only way a specification stops being able to lie about the language it describes. It could, until recently: the program that stood here used use std/io, impl Error for LoadError and json.decode<[Task]> — three things that did not exist — and nothing noticed, because nothing was checking.
Two were the appendix being wrong about the language and were corrected. The third was the language being unfinished, and it was built rather than explained away: LoadError below is a real concrete error type, and the test is what says so.
use std/errors
use std/fs
use std/json
use std/http
@derive(Decode)
pub struct Task {
pub id: int
pub title: str
pub var done: bool
}
impl Display for Task {
fn show(self) -> str {
let mark = if self.done { "x" } else { " " }
return "[\(mark)] \(self.id). \(self.title)"
}
}
// A bare variant names one of this module's own enums or the prelude's (§13.1),
// so `std/fs`'s `Missing` could not be taken for one here. A pattern may also be
// written qualified, `LoadError.Absent(path)` (§9.3).
pub enum LoadError {
Absent(path: str)
Malformed(path: str, detail: str)
}
impl Error for LoadError {
fn message(self) -> str {
return match self {
Absent(path) => "no task file at \(path)",
Malformed(path, detail) => "\(path) is not valid task JSON: \(detail)",
}
}
}
pub fn load(path: str) -> ([Task], error) {
if !fs.exists(path) {
return _, LoadError.Absent(path: path)
}
let (bytes, err) = fs.read(path)
check errors.wrap(err, "reading \(path)")
let (doc, perr) = json.parse(bytes)
if perr != nil {
return _, LoadError.Malformed(path: path, detail: perr.message())
}
var tasks: [Task] = []
for item in json.items(doc) {
let (task, derr) = Task.decode(item)
check errors.wrap(derr, "\(path) has a task that is not one")
tasks.push(task)
}
return tasks, nil
}
pub async fn sync(tasks: [Task], endpoint: str) -> (int, error) {
var uploaded = 0
let pending = filter(tasks, |t: Task| !t.done)
for task in pending {
let (res, err) = await http.post(endpoint, "\(task.id)")
check errors.wrap(err, "uploading task \(task.id)")
if res.status != 200 {
return _, errors.new("server returned \(res.status)")
}
uploaded = uploaded + 1
}
return uploaded, nil
}
pub async fn main() {
let (tasks, err) = load("tasks.json")
if err != nil {
io.error("could not load tasks: \(err.message())")
return
}
for task in tasks {
io.print(task.show())
}
let (count, serr) = await sync(tasks, "https://api.example.com/tasks")
if serr != nil {
io.error("sync failed: \(serr.message())")
return
}
io.print("synced \(count) tasks")
}
Appendix B — Keyword census
| Keyword | Purpose |
|---|---|
async await | Concurrency |
as | Explicit conversion |
break continue for if else match return | Control flow |
check | Error propagation |
defer | Scope-exit release |
enum struct trait type | Type declaration |
false true nil | Literals |
fn | Function declaration |
impl | Method and trait implementation |
in | Iteration |
let var | Bindings |
pub | Visibility |
self | Receiver |
use | Import |
Total: 27. Go has 25, C has 32, Rust has 39, Swift has over 90.
The count is asserted by a test: kinds::KEYWORDS.len() == TokenKind::KEYWORD_COUNT in crates/kite-lexer. It cannot drift from the implementation without failing the build.