Types
Khora is statically typed with inference for ordinary local expressions and explicit annotations where a boundary or ambiguity needs one.
Type annotations
Section titled “Type annotations”Bindings:
let count: Int = 42;let names: List<String> = [];List has to be imported. There is no prelude: List, Dict, Option, Result, print and everything else in std::core must be named in an import in every file that writes them, including the types that look built in. The snippets on this page show the annotation under discussion and not the import std::core::{List}; above it; Declarations has the rule. Only Int, Float, Bool, Char, String and the fixed-width numerics are keywords of the language rather than library names.
Parameters and returns:
fn add(left: Int, right: Int) -> Int { left + right}Path types
Section titled “Path types”A named type is a path, optionally with generic arguments:
IntStringapp::model::UserList<User>Result<User, DbError>Compile-time path segments use ::.
()() is both the unit type and unit value.
Tuple types
Section titled “Tuple types”(Int, String)(Int, String, Bool)(Int,)Parentheses without a comma group a type:
(Int)Record types
Section titled “Record types”{ id: Int, name: String,}Record fields may be explicitly mutable:
{ mut count: Int, name: String,}A mut field can be assigned through a value of that record type; an ordinary field cannot.
let seen: Tally = { name: "hits", count: 0 };seen.count = seen.count + 1;The annotation is load-bearing, and there are no anonymous records. A
record literal is checked against a declared type, so { name: "hits", count: 0 }
is a Tally because something said so — the annotation here, a parameter’s
type at a call, or a function’s return type. A literal with nowhere to get a
type from is refused:
error: no record type has exactly the fields `hit`, `value`which most often means a let with no annotation, or an accumulator handed to
List::fold whose shape was never declared. Declaring the type is the fix:
pub type Pick = { take: Bool, value: String };This is what in-place aggregation is written out of, and the fast shape for grouping by a small key — counters updated where they sit, rather than a new value per event:
let buckets: Array<Tally> = Array::from_fn(3, fn i => { name: "b${i}", count: 0 });Array::get(buckets, 1).count = Array::get(buckets, 1).count + 5;Array::from_fn and not Array::new: new puts the same value in every
cell, which is invisible for a record nobody can change and is one counter with
three names the moment a field is mut. from_fn calls its function per cell.
A record with a mut field cannot cross into a fiber. Two fibers writing one
record is a data race, and the type says so; Sharing is what does
cross.
Variant types
Section titled “Variant types”A value that is exactly one of several named cases. Other languages call this a discriminated union, a tagged union, or a sum type; Rust and Swift spell it enum. A match on one is checked for exhaustiveness, so a case you forget is a compile error rather than a surprise.
| None| Some(value: A)A normal named declaration uses a variant type on the right-hand side:
pub type Option<A> = | Some(value: A) | None;Payloads may be named:
| Point(x: Int, y: Int)or positional:
| Pair(Int, String)Function types
Section titled “Function types”Unary function:
A -> BSeveral parameters are represented by the parameter tuple on the left:
(A, B) -> CFunction arrows are right-associative:
A -> B -> Cmeans a function from A to a function from B to C.
Capability clauses on function types
Section titled “Capability clauses on function types”Request -> Response with { db: Db }Open capability row:
A -> B with 'efA function type can carry both requirements and failures:
Request -> Response with { db: Db, clock: Clock } raises DbError + ValidationErrorThe clauses belong to the arrow they follow.
One Unicode scalar value, thirty-two bits wide.
fn initial(name: String) -> Option<Char> { String::char_at(name, 0) }Not a String of length one. A String can be empty or hold a thousand characters, so a function taking one has to decide what those mean; a Char cannot be either. It lives in a register, so scanning a string a character at a time allocates nothing.
A scalar value is not every 32-bit number. The range stops at 0x10FFFF, and the surrogates 0xD800 to 0xDFFF are a hole in the middle of it — they exist only to encode a pair in UTF-16 and are not characters. Char::from_code stops the program on either, the way U8::of does on a number that does not fit.
Char::code goes the other way and cannot fail.
Failure rows
Section titled “Failure rows”+ joins the failure types of a raises row:
DbError + ValidationError + HttpErrorFor a declaration:
fn serve() -> Response raises DbError + HttpError{ // ...}Only in a raises or with clause. + builds a row, and a row is what those two clauses take. Written anywhere else it is refused:
fn hold(r: Result<Int, A + B>) -> Int // errorA Result holds one error type. Handle a wider row with catch, which matches per type and never has to name a combined type.
There is no anonymous union type — no way to write “an Int or a String” inline, without declaring anything. A named variant type, which other languages call a discriminated union or tagged union, is how “one of several” is expressed, and the compiler checks a match on one for exhaustiveness. + in a bound (T: Eq + Show) is the other meaning of the symbol and means the parameter implements both.
Generic type arguments
Section titled “Generic type arguments”List<String>Map<String, User>Matrix<Float, 4, 4>Type arguments can include integer literal types for const-generic parameters.
Row variables
Section titled “Row variables”A row variable starts with ':
'ef'erThey are used where an API is polymorphic over capabilities or failures:
A -> B with 'ef raises 'erThe name after ' has no built-in meaning. 'ef for capabilities and 'er for errors are conventions, spelled with two letters so they cannot be read backwards: 'e alone looks like “errors” to anybody arriving from a library that calls them that.
Record rows and open tails
Section titled “Record rows and open tails”A closed capability-shaped row:
{ db: Db, clock: Clock }A row with an open tail:
{ db: Db | 'ef }Rows may merge additional row values in the tail position:
{ 'left | 'right | clock: Clock }An failure row names failure types rather than capabilities, and its entries are bare:
{ Oops }{ Timeout | Refused }The bare spelling above is the one a raises clause takes. In
type-argument position — the one place a row has to be written down rather
than inferred — an failure row’s entries are labelled, and an failure row labels
each type with its own name. A Fiber’s second parameter is a row, so the form
that works everywhere is:
fn stop(f: Fiber<(), { Oops: Oops }>) -> () { Fiber::cancel(f) }
let f: Fiber<(), { Oops: Oops }> = Fiber::spawn(work);Fiber<(), Oops> is a type where a row belongs; it is refused, and the
compiler prints the shape it wanted:
error: this argument: expected `() -> () raises { | Oops }`, found `() -> () raises { Oops: Oops }`; `Oops` is a type and a row belongs here. A row's entries are labelled, and an failure row labels each type with its own name — write it `{ Oops: Oops }`. The bare name is right in a `raises` clause and only a type argument needs the braces{ Oops } is not the same thing as { Oops: Oops }. In a let
annotation it happens to check, because the annotation is unified against an
inferred type. In a parameter or return type it is read as { | Oops } — an
open-tail row variable named Oops — the declaration is accepted silently,
and the mismatch surfaces at the call site with the caret on the argument
rather than on the signature. Write the labelled form and it is right in both
places.
Most code never writes one, because a signature’s raises clause takes the
types directly (raises Oops) and everything else infers. Fiber<A, 'er> in
the standard library and Fiber<(), 'er> in the concurrency reference are row
variables, which need no braces — which is why the concrete form is easy to
miss.
Rows are structural; ordinary declared ADTs remain nominal.
Explicit polymorphic types with forall
Section titled “Explicit polymorphic types with forall”forall<A>. A -> ASeveral parameters, including const parameters, use the normal generic parameter list:
forall<A, const N: Int>. Vector<A>Most named generic functions do not need explicit forall; the declaration’s own type parameter list introduces their parameters.
A forall value cannot be called yet. The type is accepted as a parameter or an annotation, and a polymorphic function can be passed as one, but applying it inside the body is a gap in the compiler — Generics has the error and what to write instead.
Opaque types
Section titled “Opaque types”A type declaration may omit its definition:
pub type Handle;The representation is not available to ordinary source using the type.
Wrappers and named data
Section titled “Wrappers and named data”A type declaration over an existing type gives a domain name to a value, and gives it a type of its own:
pub type UserId = Int;pub type OrderId = Int;These are distinct types, not other spellings of Int. A UserId is not
accepted where an Int is wanted, an Int is not accepted where a UserId
is, and a UserId is not an OrderId — which is the reason to write one:
error: this argument: expected `UserId`, found `OrderId`Build one by calling the type’s name, and open it by matching on it, the shape a Rust tuple struct has:
let id = UserId(1);
fn number(id: UserId) -> Int { match id { UserId(value) => value }}derive applies as it does to anything else, and is usually wanted: without
Eq and Ord a UserId cannot be a Dict key, and without Show it cannot
go in a ${..} hole.
derive(Eq, Ord, Show)pub type UserId = Int;Show prints UserId(1), not UserId::UserId(1) — the one case is the type.
UserId(1) is the spelling everywhere, including modules that import the type
rather than declare it. The qualified UserId::UserId(1) also resolves, and
matching works bare across modules too: match id { UserId(value) => value }.
The underlying type may be anything, a generic one included, which is how a long type gets a short name:
pub type Books = Dict<Currency, Bucket>;Khora has no transparent alias: no form meaning “another spelling of the
same type”. Where that is what is wanted, write the type out, or take the
wrapper and the one match it costs.
The other two ways a declaration names data, for comparison — a record:
pub type User = { id: UserId, name: String,};and a variant:
pub type Lookup = | Found(user: User) | Missing;See Declarations for visibility, derive, and complete declaration forms; see Generics for bounds, const parameters, variance, and polymorphism.
Khora 0.3.0built frombd964738afb6on 2026-10-17 20:41ZVersioningSecurityContributingReleasesSource