Chapter 4 · Compound Types
The seven primitives from chapter 3 give you raw pieces. To
build real programs, you glue them into structures: aggregates
with named fields, lists, tuples, and the two stdlib types you’ll
reach for more than any other, Option and Result. If I had to
keep a single stdlib decision, I’d keep those two.
This chapter covers all of that. Sum types (type Tag = Foo | Bar(Int)) you saw in the tour deserve their own chapter
and we treat them in chapter 5.
4.1 Records
A record is an aggregate with named fields. You declare it
with type and braces:
type Point = { x: Int, y: Int }
type Employee = {
name: String,
age: Int,
salary: Int,
}
The trailing comma on the last field is optional but idiomatic: it means adding a new field doesn’t touch the previous line, which keeps git diffs clean.
To build a record value, you write the type name followed by braces with the fields:
let origin = Point { x: 0, y: 0 }
let p = Point { x: 3, y: 4 }
let ada = Employee { name: "Ada", age: 30, salary: 1500 }
To read a field, you use .:
println("p is at (#{p.x}, #{p.y})")
println("#{ada.name} is #{ada.age}")
That’s enough for the day-to-day. Three details worth pinning down:
-
Records are immutable.
p.x = 7does not exist. If you need a point with one different field, you build a new one with the spread sugar:let p = Point { x: 3, y: 4 } let p2 = Point { ...p, x: 7 } # Point { x: 7, y: 4 }...pcopies all fields ofp; the named initializers after it (herex: 7) override what they repeat. It’s the same idea as list spread (see §4.3) applied to records. -
Records are nominal.
Point { x: Int, y: Int }and anotherPosition { x: Int, y: Int }with the same fields are distinct types. The compiler doesn’t conflate them even if they have the same shape. I wanted it that way: if you want a position, say position. -
Spread has rules. Only one spread per literal, and it must come first —
Point { x: 7, ...p }is a parse error. The initializers that follow must be named (x: expr), not punned or positional. These are deliberate: they make it obvious who wins on a duplicate.
4.1.1 Private fields
Record fields are public by default: any module that imports
the type can read them and name them when constructing a
literal. The priv keyword in front of a field name flips
that default:
# module `safe`
pub type Account = {
name: String, # public by default
priv balance: Real, # private to module `safe`
}
pub fn open(name: String) : Account =
Account { name: name, balance: 0.0 }
pub fn deposit(a: Account, amount: Real) : Account =
Account { ...a, balance: a.balance + amount }
pub fn balance_of(a: Account) : Real = a.balance
From inside the safe module, the balance field reads and
writes like any other. From outside, it doesn’t:
import safe
fn main() {
let a = safe.open("savings")
println("#{a.name}") # OK, `name` is public
println("#{safe.balance_of(a)}") # OK, going through the getter
# The next two lines don't compile:
# println("#{a.balance}") # ← field `balance` is private to module `safe`
# let d = safe.Account { # ← cannot construct `Account` from outside …
# name: "x",
# balance: 1000.0,
# }
}
The rule is strict: no external reads, no mention inside a
construction literal. If module safe wants a consumer to
create accounts, it exposes constructors (open) and
operations (deposit) that preserve the invariants; the
raw field stays hidden.
This turns a record into a lightweight abstract type: public shape, interior under the author’s control. We’ll use this in ch. 17 to hide the store actor’s state, and in ch. 18 so ledger balances can’t be constructed from outside the domain module.
4.2 Field access and destructuring
Accessing with . is fine for one or two fields. When you
need several at once, destructuring is cleaner:
fn distance_squared(a: Point, b: Point) : Int {
let Point { x: ax, y: ay } = a
let Point { x: bx, y: by } = b
let dx = ax - bx
let dy = ay - by
dx * dx + dy * dy
}
When the field names work as variables, you can drop the :
and just give the field name — binding the field to a variable
of the same name:
fn describe(p: Point) : String {
let Point { x, y } = p
"(#{x}, #{y})"
}
Destructuring works in match too. That’s its most useful
form: deciding what to do based on the specific values of
the fields:
fn classify(p: Point) : String =
match p {
Point { x: 0, y: 0 } -> "origin"
Point { x: 0, y: _ } -> "Y axis"
Point { x: _, y: 0 } -> "X axis"
Point { x, y } -> "point at (#{x}, #{y})"
}
The compiler checks exhaustiveness, just like with sum types:
remove the last arm and it stops compiling. Exhaustiveness
over Int fields is covered by the final Point { x, y }
arm, which matches any Point. Without that catch-all arm
the match is not total.
4.3 Lists
A list is an immutable, linked sequence of values of the same
type. The type is written with brackets around the element
type: [Int], [String], [Point], [[String]] (list of
lists).
Building literals:
let primes = [2, 3, 5, 7, 11]
let empty: [Int] = []
kaikai also has range literals, which the type system treats as just another list:
let r1 = [1..10] # [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
let r2 = [1..10..2] # [1, 3, 5, 7, 9]
let r3 = [10..1..-1] # [10, 9, 8, ..., 1]
One cost detail worth knowing: the range is lazy. The
runtime stores it as three numbers (start, end, step), not ten
cells, and elements are generated only when something consumes
them. [1..1_000_000] |> list.sum never materializes a million
cells; to your code it is indistinguishable from the handwritten
list.
To extend an existing list, use ... (spread):
let xs = [1, 2, 3]
let ys = [0, ...xs, 99] # [0, 1, 2, 3, 99]
It takes any number of spreads, anywhere in the literal:
[...a, ...b] works, and so does [...xs, 3, 4].
Reach for it when you’re putting an element on the front. The
exact same list comes out of [h] ++ t:
fn prepend(h: Int, t: [Int]) : [Int] = [h, ...t]
fn prepend_concat(h: Int, t: [Int]) : [Int] = [h] ++ t
The difference isn’t only in the reading. [h, ...t] says “a
list starting with h and continuing with t” — the same shape
as the pattern you’ll take it apart with later — and emits the
cons directly. [h] ++ t says “glue these two lists”, and to do
that it builds a one-element list whose only purpose is to be
glued: one allocation per call.
Measuring that has a catch worth knowing before you try. Put
both forms in a bench whose body discards the result and each
iteration costs less than the clock’s resolution, so the report
hands you noise (§7.4 shows how to spot it). With the result
consumed, built with --release, three million iterations over
an eight-element tail, on my machine: 0.05 s for the spread
against 0.11 s for the concat.
kai lint flags the second form as
list_concat_literal_to_spread.
None of which is an argument against ++. Concatenating two
lists that both already exist is exactly what it’s for.
To take them apart, the patterns of match:
fn sum(xs: [Int]) : Int =
match xs {
[] -> 0
[h, ...t] -> h + sum(t)
}
[] matches the empty list. [h, ...t] matches any non-empty
list, binding h to the first element (“head”) and t to the
rest (“tail”). These two patterns cover every possible case,
making the match exhaustive.
More specific patterns are also legal:
match xs {
[] -> "empty"
[only] -> "one: #{only}"
[first, second, ...] -> "at least two"
}
The compiler requires you to cover all cases. If you write
just [] and [h, ...t], it’s enough for any list — but if
you want to single out “exactly one element”, you write
[only] before the catch-all.
A language convention: if the tail matters, give it a
name — [h, ...t] and then use t. If it doesn’t, write
... alone, no name — [h, ...]. It’s the difference
between “I take the head, the rest I keep for later” and “I
take the head, the rest I discard”. An invented name that
goes unused is visual noise; the bare form communicates intent
without forcing a name that adds nothing.
For indexed access, the stdlib exposes list.nth:
let first = list.nth(xs, 0) # Option[Int]
let third = list.nth(xs, 2) # Option[Int]
Notice the return type: Option[a], not a. A linked
list does not guarantee that an index exists — if you ask for
element 99 of a list of three, there is no value to return.
The type forces you to consider it. This is consistent with
Option and Result (see §4.5): kaikai prefers to enclose
the possibility of failure in the type rather than abort at
runtime.
Two more things about indexed access. One: it is O(n) —
lists are linked, not indexed; walking to position i costs
i steps. For random access there are two other types:
Vec[T], coming up in a moment, and Array[T], which we
cover in chapter 12.
The other: the syntax xs[i] that some languages use for
lists is a type error on a list — indexing is for Vec and
Array. The reason is the same: xs[i] suggests cheap,
guaranteed access, which would be lying about a linked list.
For most code, you don’t want to index by hand anyway.
Recursion over [h, ...t] or the higher-order functions of
chapter 6 (map, filter, reduce) are the natural way to
process lists.
Lists are immutable. There is no xs[0] = 99. If you
need a modified list, you build a new one.
Vec[T] — indexing without giving up values
When you genuinely need to index, the default answer is
Vec[T]: a flat, contiguous buffer with O(1) access that
behaves like a value. No effects: not one operation
carries Mutable in its row.
A list literal in a Vec position builds the vector directly,
with no linked list in between:
fn main() : Unit / Stdout = {
let a: Vec[Int] = [10, 20, 30]
println("a[1] = #{a[1]}")
}
$ kai run examples/ch04/11_vec.kai
a[1] = 20
The annotation is what decides: a bare [10, 20, 30] is still
a list.
The interesting part is that push and set return the
resulting vector rather than modifying one, so two names
pointing at the same Vec never tread on each other.
Underneath, the runtime mutates in place whenever the buffer
has a single owner — the same reference counting as chapter
13 — and copies only when it is shared. Thread the vector
linearly, accumulator style, and building it costs one
allocation.
A slice is a constant-cost view: v[2..4] copies no elements,
it shares the buffer.
And Array[T], which shows up in chapter 12, is the other
face: the mutable reference, where writes are observable
through every alias and each one demands / Mutable. The rule
is short: Vec when you want a collection that behaves like a
value, Array when you want genuine shared mutation.
4.4 Strings, not lists of chars
Worth pausing on something many languages mix up: in kaikai,
a String is not a list of Char. They are distinct
types:
let s: String = "hi"
let cs: [Char] = ['h', 'i']
s and cs are not interchangeable. You can’t write s[0]
expecting a Char, and you can’t pass a String where
[Char] is expected.
Why? Because in Unicode there is no simple correspondence between “character” and “index”. An emoji can occupy several codepoints; an accented letter may have one or two representations; a grapheme can skip bytes and codepoints arbitrarily. Treating a string as a list of chars forces a decision about what counts as “a character” — and every decision is wrong for some case.
Internally, a String is a UTF-8 buffer. The operations
that make sense live in the string module of the stdlib, and
there kaikai is deliberate about a distinction many languages
paper over: the difference between bytes and Unicode
codepoints. They are not the same thing the moment you leave
ASCII, and each function’s name tells you which unit it works in.
Take "café" and measure it all four ways.
length(s)counts bytes, in O(1). For"café"it returns 5, not 4: theétakes two bytes in UTF-8.byte_length(s)is the same number with the unit said out loud, for when the code reads better if nobody has to assume.char_count(s)counts Unicode codepoints. For"café"it returns 4 — the length a reader would say aloud.chars(s)decodes the buffer and hands back the codepoints as[Char];from_charsputs them back together.bytes(s)hands back the raw octets as[Byte], one per byte, splitting any multibyte codepoint into pieces;from_bytesis its inverse.
Notice that the two views have different types: [Char] is
characters, [Byte] is octets. That is not cosmetic. It means you
cannot cross them by accident — handing from_chars the result of
bytes(s) does not compile, because an octet is not a character
and the compiler knows it.
import core.string
import core.list
fn main() {
let s = "café"
println("bytes: #{string.length(s)}") # 5
println("codepoints: #{string.char_count(s)}") # 4
println("chars: #{list.length(string.chars(s))}") # 4
println("bytes list: #{list.length(string.bytes(s))}") # 5
println("round trip: #{string.from_bytes(string.bytes(s))}")
}
$ kai run examples/ch04/07_strings.kai
bytes: 5
codepoints: 4
chars: 4
bytes list: 5
round trip: café
slice(2,2): fé
byte_slice(2,3): Some(fé)
byte_slice(2,2): None
Those last three lines are the two ways to take a piece out of a string. What separates them is the unit:
slice(s: String, from: Int, len: Int) : String
byte_slice(s: String, from: Int, len: Int) : Option[String]
slice counts codepoints. Taking "fé" out of "café" is
asking for two characters starting at the second: slice(s, 2, 2).
Overrun the length and it clamps rather than failing —
slice(s, 1, 9) gives "afé" — and it can never hand you a broken
string, because it does not know how to cut a character in half.
byte_slice counts bytes, which is what you need when the
offsets come from somewhere else: an index_of, a parser, a
protocol that measures in octets. Since any byte can land inside a
character, it returns Option: byte_slice(s, 2, 3) gives
Some("fé") because 2 and 5 are codepoint boundaries, and
byte_slice(s, 2, 2) gives None because 4 sits in the middle of
the é. If you would rather snap the offset than have it refused,
that is what is_char_boundary, floor_char_boundary and
ceil_char_boundary are for.
The mental rule is short: the name tells you the unit.
length, byte_length, bytes and byte_slice speak bytes;
char_count, chars and slice speak codepoints. Search comes in
the same pairs: index_of returns a byte offset, which feeds
byte_slice, and char_index_of returns a codepoint index, which
feeds slice.
That length counts bytes while slice counts codepoints is not a
lapse in coherence. length is O(1) and can’t break anything; a
cut by byte can. Each operation keeps the unit that won’t cost you.
(Graphemes like an “é” built from e plus a combining accent are
yet another layer; there even codepoints fall short, but you rarely
need them.)
The same care shows up in case folding. core.char ships
to_upper, to_lower, is_upper and is_lower for ASCII. Its
sibling core.char_unicode extends all four to the alphabets
whose mapping is a uniform codepoint offset: Latin-1, Latin
Extended-A, Greek and Cyrillic. Whatever falls outside that rule
passes through untouched, and kaikai would rather tell you so in
the module’s name than pretend to full Unicode coverage:
import core.char
import core.char_unicode
import core.list
import core.string
fn upper(s: String) : String =
string.from_chars(list.map(string.chars(s), char_unicode.to_upper))
fn main() : Unit / Stdout {
Stdout.print(upper("café"))
Stdout.print(upper("привет"))
Stdout.print(upper("omega"))
Stdout.print(upper("ωμεγα"))
Stdout.print(upper("ωμέγα"))
Stdout.print(upper("☃ 42"))
Stdout.print("#{char.to_upper('k')}")
}
upper is all it takes: decode the string to codepoints with
chars, apply to_upper to each one with map, and put the string
back together with from_chars. The last line reaches for
core.char rather than core.char_unicode, because ASCII needs no
more than that.
$ kai run examples/ch04/10_case_folding.kai
CAFÉ
ПРИВЕТ
OMEGA
ΩΜΕΓΑ
ΩΜέΓΑ
☃ 42
K
The ΩΜέΓΑ line marks the edge: ωμεγα folds whole, while in
ωμέγα the έ stays down, because its uppercase form does not sit
at a fixed offset. Full Unicode folding needs tables, and those do
not live in the stdlib yet.
For concatenation, you saw it in chapter 3: use ++:
let greeting = "Hello, " ++ name
For interpolation, #{...} inside a literal "...".
++ is fine for joining two or three pieces. But beware of
building a large string by gluing fragments in a loop: because a
String is immutable, every ++ copies the whole accumulator to
produce a new one, which lands you in O(n²) — the classic
quadratic of concatenation. That is what StringBuilder is for: a
text accumulator that holds the fragments as you add them and only
joins them at the end, in a single pass, with build. Appending is
amortized O(1), and the whole assembly is O(n).
import string_builder
import core.list
fn join(names: [String]) : String {
let sb = list.foldl(names, string_builder.new(),
(b, n) => string_builder.append(b, "#{n}, "))
string_builder.build(sb)
}
$ kai run examples/ch04/09_string_builder.kai
ana, ben, cleo,
append rides the Mutable effect — internally it writes into
the builder’s fragment array — while build is pure: it only reads
and joins. Notice that join declares no / Mutable in its
signature even though it drives append: since the builder is born
and dies inside, never escaping, kaikai masks the effect at the
function boundary. The rest of the API (new, with_capacity,
append_char, len, is_empty) is in kai doc string_builder.
4.5 Option and Result: the daily tools
You saw these two in chapter 2:
type Option[a] = None | Some(a)
type Result[a, e] = Ok(a) | Err(e)
Here we look at them in use. They are two generic sum types
from the stdlib that you will use constantly. A reminder
of the idea: Option represents “there might be no value”;
Result, “there might be a value or an error”.
A function that finds the first even element of a list:
fn first_even(xs: [Int]) : Option[Int] =
match xs {
[] -> None
[h, ...t] -> if h % 2 == 0 { Some(h) } else { first_even(t) }
}
And the caller has to consider both cases explicitly:
match first_even(xs) {
Some(n) -> println("found: #{n}")
None -> println("no evens")
}
A function that parses an age from a string can fail in two
distinct ways — and that’s exactly what Result is for:
type AgeError = NotNumeric | OutOfRange
fn parse_age(s: String) : Result[Int, AgeError] =
match string_to_int(s) {
None -> Err(NotNumeric)
Some(n) ->
if n < 0 or n > 130 { Err(OutOfRange) }
else { Ok(n) }
}
Result[Int, AgeError] reads as “an Int or an error of
type AgeError”. The successful value is in the first parameter
and the error in the second, as in Rust and most modern
languages.
Three patterns you’ll see often:
- Chain a failure with
!. If you have an expression that returnsResult[A, E]and you want “if it fails, propagate the error; otherwise continue with the value”, you writeexpr!. You saw this in §2.3. - Higher-order functions:
option.map,option.and_then,result.map_err. We cover them in chapter 6. - Convert between the two:
option.ok_or(error)takes anOption[a]and an error of typeeand returns aResult[a, e]. Useful when the information lost byNoneisn’t enough.
Option and Result are sum types like any other. We’ve
separated them from chapter 5 because their role in
day-to-day design is central — you’ll use them before you
start declaring your own sum types.
4.6 Tuples
A tuple is a positional aggregate: like a record, but without field names. The syntax is parentheses with elements:
let point2d = (3, 4)
let trio = ("Ada", 30, true)
The type is written the same way: (Int, Int), (String, Int, Bool).
kaikai admits tuples of arity 2 to 4. There are no
1-element tuples — a single parenthesis, (e), is grouping,
not a tuple. And (a, b, c, d, e) is a parse error.
Why the bound? Because long tuples are unreadable. If you’re hitting 5 elements, almost always what you wanted was a record with named fields.
To take a tuple apart, destructuring:
fn divmod(a: Int, b: Int) : (Int, Int) = (a / b, a % b)
fn main() {
let (quotient, remainder) = divmod(17, 5)
println("17/5 = #{quotient}, remainder #{remainder}")
}
Internally, tuples are sugar over three records from the
stdlib: Pair[A, B] for arity 2, Triple[A, B, C] for 3,
Quad[A, B, C, D] for 4. The declaration
let p = (1, 2)
is exactly equivalent to
let p = Pair { fst: 1, snd: 2 }
This is useful to know when you see a type Pair[Int, String]
in a stdlib signature: now you know it’s the same as
(Int, String), and you can destructure it with
let (a, b) = p.
Tuple or record?
When in doubt, use a record. Tuples are convenient for
cases where names don’t add — the result of divmod, where
“the first value is the quotient and the second the
remainder” is information the reader already gets from the
function name. Or when the aggregate lives one step:
xs |> map((e) => (e.name, e.age))
But as soon as the same aggregate appears more than once, or
crosses a module boundary, or its shape is something the
reader can’t deduce, prefer a record. An Employee is much
easier to read than a (String, Int, Bool, String, Int).
4.7 Hash maps and sets
Everything we’ve seen so far is immutable: a new record
doesn’t mutate the old one, a list with one more element is a
new list. For most code that’s exactly what you want. But
sometimes you need a real associative table — insert and look
up by key in near-constant time — and building a fresh record
on every insertion won’t cut it. For that the stdlib ships two
mutable structures: HashMap[k, v], which maps keys to
values, and HashSet[a], a set with no duplicates.
That they’re mutable shows up in the effect row: their
operations yield Mutable. If you come from Python or Java, a
dictionary that changes in place feels obvious; what’s new here
is that the language says so in the type. A function that
touches a HashMap carries / Mutable in its signature, and
the compiler makes you declare it. That’s not red tape: it’s
the same honesty as every other effect. Immutability is still
the default; mutation is there, marked.
Key access uses indexing, m[key], which returns an Option
— Some(v) if the key is present, None if it isn’t — so a
missing key never blows up in your face:
import collections.hashmap as hashmap
let current = match m[w] {
Some(n) -> n
None -> 0
}
hashmap.put(m, w, current + 1)
A full example: count how often each word in a list appears,
first with a HashMap, and along the way use a HashSet to
count how many distinct words there are.
import collections.hashmap as hashmap
import collections.hashset as hashset
fn word_frequencies(words: [String]) : hashmap.HashMap[String, Int] / Mutable {
let m = hashmap.empty()
count(m, words)
m
}
fn count(m: hashmap.HashMap[String, Int], words: [String]) : Unit / Mutable = match words {
[] -> ()
[w, ...rest] -> {
let current = match m[w] { Some(n) -> n None -> 0 }
hashmap.put(m, w, current + 1)
count(m, rest)
}
}
fn main() : Unit / Stdout + Mutable {
let text = ["sun", "sea", "sun", "wind", "sea", "sun"]
let m = word_frequencies(text)
match m["sun"] {
Some(n) -> Stdout.print("sun appears #{n} times")
None -> Stdout.print("sun does not appear")
}
Stdout.print("distinct words: #{hashmap.size(m)}")
# A HashSet drops duplicates on insertion.
let seen = hashset.empty()
mark(seen, text)
Stdout.print("unique via set: #{hashset.size(seen)}")
}
fn mark(s: hashset.HashSet[String], words: [String]) : Unit / Mutable = match words {
[] -> ()
[w, ...rest] -> { hashset.add(s, w); mark(s, rest) }
}
$ kai run examples/ch04/08_maps.kai
sun appears 3 times
distinct words: 3
unique via set: 3
hashmap.put inserts or replaces in place; hashmap.get is
the named form of m[key]; size, keys, values, remove
and contains round out the day-to-day. The HashSet is the
valueless face of the same idea: add, contains, size,
plus the set operations union, intersection and
difference. The full list, with signatures, comes out of
kai doc collections/hashmap and kai doc collections/hashset
(ch. 16 covers kai doc).
One note on order: neither the HashMap nor the HashSet
promises a traversal order. keys, values and to_pairs
hand you the elements in the internal bucket order, which is
not insertion order. If you need order, sort at the end or
reach for the stdlib’s ordered structure (Map, built on an
AVL tree).
Map also compares by content: two maps holding the same
pairs are equal even when they were built by inserting in a
different order. That makes it the right structure when the
result leaves the function and something downstream will
compare it against an expectation. The bridge between the two
families lives in collections/convert: convert.to_map(h)
freezes a HashMap once the fast build is done, and
convert.to_hashmap(m) goes the other way.
Exercises
4.1. Define a record Book with fields title, author
and pages. Write a function fn short(b: Book) : Bool that
returns true if the book has fewer than 200 pages. Build
two books and try the function.
4.2. Write fn maximum(xs: [Int]) : Option[Int] returning
the largest element of the list, or None if the list is
empty. Use recursion and match.
4.3. Rewrite fn parse_age from §4.5 so it distinguishes
three errors: not numeric, negative age, age > 130. Use
a sum type with three constructors and a Result.
4.4. Define a function fn split(xs: [Int]) : ([Int], [Int]) returning a tuple with the evens and odds of xs,
each in original order. Why a tuple and not two return
values? And why a tuple and not a record?
4.5. Given a [Point] (with Point = { x: Int, y: Int }),
write fn center(ps: [Point]) : Option[Point] returning the
average of coordinates, or None if the list is empty. Hint:
you’ll need two passes or an accumulator, and int_to_real
to average — but note the final result has to be a Point
with Int fields, so you’ll also need real_to_int (or
settle for the integer part).
4.6. On paper, without writing code, draw what happens in memory when you run these three lines:
let xs = [1, 2, 3]
let ys = [0, ...xs]
let zs = [99, ...xs]
How many lists were created? How many elements were copied?
Are there cells that xs, ys, zs share? The answer
helps you understand why immutability isn’t expensive when
the structures are linked.