Skip to content
You are reading next, which describes the unreleased compiler. Khora is unstable before v1: syntax, standard-library APIs and behavior may change. The current release is documented under v0.4.

Types

Khora is statically typed with inference for ordinary local expressions and explicit annotations where a boundary or ambiguity needs one.

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
}

A named type is a path, optionally with generic arguments:

Int
String
app::model::User
List<User>
Result<User, DbError>

Compile-time path segments use ::.

()

() is both the unit type and unit value.

(Int, String)
(Int, String, Bool)
(Int,)

Parentheses without a comma group a type:

(Int)
{
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.

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.

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)

Unary function:

A -> B

Several parameters are represented by the parameter tuple on the left:

(A, B) -> C

Function arrows are right-associative:

A -> B -> C

means a function from A to a function from B to C.

Request -> Response with { db: Db }

Open capability row:

A -> B with 'ef

A function type can carry both requirements and failures:

Request -> Response
with { db: Db, clock: Clock }
raises DbError + ValidationError

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

+ joins the failure types of a raises row:

DbError + ValidationError + HttpError

For 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 // error

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

List<String>
Map<String, User>
Matrix<Float, 4, 4>

Type arguments can include integer literal types for const-generic parameters.

A row variable starts with ':

'ef
'er

They are used where an API is polymorphic over capabilities or failures:

A -> B with 'ef raises 'er

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

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.

forall<A>. A -> A

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

A type declaration may omit its definition:

pub type Handle;

The representation is not available to ordinary source using the type.

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