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.
Field visibility
Section titled “Field visibility”A field of a declared record is private to the module that declares the type,
unless it is marked pub:
pub type Pair<K, V> = { pub key: K, pub value: V };pub type Tally = { pub name: String, pub mut count: Int };pub type Email = { text: String };pub comes before mut, as it comes before fn.
Inside the declaring module nothing changes. Everywhere else, a private
field cannot be read (e.text), assigned (e.text = ..), named in a literal
({ text: .. }) or an update ({ ..e, text: .. }), or bound in a pattern
(Email { text }, let Email { text } = e).
A value with any private field can only be built by its own module. A
literal or an update from outside is refused even when it names only pub
fields, because the private ones are what the module’s functions keep true.
That is what makes a checking constructor the only way to get a value:
module email::address;
import std::core::{Option, String};
/// An address that was checked once, here, and never again.pub type Email = { text: String };
impl Email { pub fn parse(text: String) -> Option<Email> { match String::index_of(text, "@") { Option::None => Option::None, Option::Some(_) => Option::Some({ text: text }), } } pub fn text(self) -> String { self.text }}Another module calls Email::parse and reads Email::text(e). Writing
{ text: "nope" } there is refused:
error: cannot build `Email` here: `text` is private to `email::address`, so only `email::address` can make one. Call one of its functions that returns `Email`, or, if `email::address` is yours, mark every field `pub`A private field does not hide a method. e.text(), the second form of
Email::text(e), calls the method from outside even though Email has a
private field of the same name.
A case’s payload is not a field and is always public, because matching on it is how a variant is used. To hide a variant’s shape, wrap it in a newtype, which is private by default (see Wrappers and named data).
derive writes code inside the declaring module, so a derived Show,
Eq, Ord, Hash or Encode reads private fields. derive(Decode) is
refused on a pub type with a private field, because it would build one from
input without calling anything that checks. Write impl Decode with
Schema::try_map over the function that checks, or mark every field pub if
nothing needs checking:
impl Decode for Email { fn schema() -> Schema<Email> { Schema::try_map(string(), "an email address", fn s => Email::parse(s)) }}Tests: a test block is inside the module it is written in. A separate
module email::address_test; is outside email::address, as it is for that
module’s private functions.
pub means something only on a field of a type declaration. In a row, on a
case’s payload and on an effect’s operation it is refused. mut is refused in
a row too: a row’s entries belong to no value, so there is nothing to assign.
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 labeled, 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 labeled, 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 labeled 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 = pub Int;pub type OrderId = pub 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 }}The value inside is private unless the declaration says pub, the same
rule a record’s fields follow: a wrapper is a record with one unnamed field.
type UserId = pub Int; is an identifier anyone may make and open, and
UserId(1) is then the spelling everywhere, including modules that import the
type rather than declare it. The qualified UserId::UserId(1) also resolves.
Without the pub, only the declaring module can call Port(..) or match
Port(n). That is what a refinement needs: a Port built only by a function
that checks the range is a port every other function can trust.
module net::port;
import std::core::{Option};
/// A TCP port, 1 to 65535. `Port::of` is the only way to get one.pub type Port = Int;
impl Port { pub fn of(n: Int) -> Option<Port> { if n < 1 || n > 65535 { Option::None } else { Option::Some(Port(n)) } }}From another module, Port(99999) is refused:
error: cannot build `Port` here: its value is private to `net::port`, so only `net::port` can make one. Call one of its functions that returns `Port`, or, if `net::port` is yours, declare it `type Port = pub ..`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 = pub Int;Show prints UserId(1), not UserId::UserId(1) — the one case is the type.
The underlying type may be anything, a generic one included, which is how a long type gets a short name:
pub type Books = pub 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 = { pub id: UserId, pub 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.4.0built frombd964738afb6on 2026-10-17 20:41ZVersioningSecurityContributingReleasesSource