Chapter 2 · Thinking in kaikai
Chapter 1 showed you the language from above. Before getting into the detail of types, functions, and modules, it is worth pausing on a handful of habits kaikai asks of you that you probably did not bring with you from Python, Java, Go, JavaScript, or C#.
This is the shortest chapter in the book, and you can skip it. If you have already programmed in Haskell, OCaml, Elixir, or Scala, what follows will sound familiar — head to Part II and we’ll meet you in chapter 3. If you come from an imperative background, give it the twenty minutes it asks for. They will save you a lot of friction across the next hundred and fifty pages.
We are not going to open up the theory of each idea — that’s the job of the chapters that follow. What I want here is to name the habit change, show it, and give you the words to recognize it when it shows up. These habits took me years to make my own, and I suspect a good part of that time went into not having anyone name them for me.
2.1 Expressions, not statements
In most of the languages you probably know, code is built from two kinds of pieces:
- Expressions, which produce a value:
x + 1,f(2),a == b. - Statements, which do not produce a value but execute
something: a two-armed
if, afor, areturn, an assignment.
Statements have to be lined up in a sequence. Expressions do not: they compose by nesting.
kaikai erases that boundary. Almost everything is an
expression. An if produces a value. A match produces a
value. A block { ... } produces a value — the value of its
last expression. A function does not need a return because
its body is the expression that gets returned.
Compare two ways of writing the same thing. The classic imperative form:
String s;
if (x > 0) {
s = "positive";
} else {
s = "non-positive";
}
print(s);
In kaikai:
let s = if x > 0 { "positive" } else { "non-positive" }
println(s)
The difference isn’t in line count but in how you think about
the code. The imperative version forces you to declare s
first, because the if cannot return anything; then it
assigns to s in each branch. In kaikai, the if is the
value, and s binds directly to the result. Declaration and
assignment merge. There is no assignment at all: s is bound
once and never changes.
The practical consequences show up quickly:
- Fewer lines, no loss of clarity. Three steps (declare, decide, assign) collapse into one.
- Fewer temporaries. If you only need a value to pass into the next call, you build it inline.
- Fewer “forgot to initialize” bugs. No declared-but-uninitialized variables.
- Smoother refactoring. An expression can be lifted into a function or replaced by another expression without disturbing the surrounding context; a statement, less so.
You see the same with match. In most languages with a
switch, each case is a statement that runs a block and then
either breaks or falls through, depending on the rules. In
kaikai, match is an expression that returns a value, and each
arm is the expression that value might be. You saw this in
chapter 1, in label and in eval. You will see it
constantly.
A related point: the body of a function can take two shapes, and the choice between them communicates intent.
fn double(x: Int) : Int = x * 2
fn classify(x: Int) : String {
if x < 0 { "negative" }
else if x == 0 { "zero" }
else { "positive" }
}
Use = and a single expression when the function is direct.
Use { ... } when there are several steps or visual separation
helps. The compiler accepts both; the difference is for the
reader.
A useful consequence of treating everything as an expression is
that chained transformations become comfortable. From the
Elixir world, kaikai borrows the pipe operator |>:
xs |> filter(is_even) |> map(double) |> list.length
is equivalent to list.length(map(filter(xs, is_even), double)). The piped form reads left-to-right, in the order the
transformations happen, and avoids intermediate names. It is
only possible because each step is an expression that can be
composed with the next.
|> is the most general of three pipe operators kaikai
provides for chaining. The other two — | (map over lists) and
|| (flat-map) — are shortcuts for the cases that appear over
and over. Chapter 6 covers the three in detail.
2.2 Immutability by default
let x = 5 binds x to the value 5. That’s it. You cannot
write x = 6 later. If your code seems to need to “change x”,
in kaikai that means one of two things:
- You actually need a new value derived from the first. The
right move is to bind another name, or to redefine
xin a nested scope. - You actually need visible mutation. That’s a real but small
case, and kaikai gives it to you, but it asks you to declare
it. Mutation of an array, for example, lives under the
Mutableeffect, which appears in the signature of any function that uses it. We’ll cover this calmly in chapter 12.
Why take this path? Because most of the time, “changing a variable” is a habit inherited from the imperative model, not a real need. When you program with values that don’t change:
- Reasoning becomes local. If
xwas bound to5on line 12, it is still5on line 30. Period. No need to hunt for who modified it in between. - Concurrency becomes simpler. Two fibers can read the same value without synchronization; nobody is going to overwrite it.
- Bugs go away. A whole category of errors (“I expected X, but at the end of the method it was Y”) just doesn’t exist.
- Tests are simpler. A pure function — input, output, no hidden state — is tested by giving it inputs and comparing outputs. That’s all.
A note on vocabulary. The usual way to bind a name is let,
which is immutable. For the cases where you really need a
local mutable cell — a counter, an accumulator, a cursor —
kaikai gives you var. := is the single mark of mutability:
it declares the cell, writes it, and a bare name reads it.
var n := 0
n := n + 1
println(int_to_string(n)) # 1
What’s worth noticing: var is not really a new
construct in the language. It is syntactic sugar over the
State effect. The line var n := 0 rewrites to a
handle ... with State[Int](0) as n { ... } covering the rest
of the block. Reading n rewrites to n.get(), and n := v to
n.set(v). The base language is the same algebraic-effects
machinery from chapter 12; what changes is the face it shows
for common cases.
What matters for your mental model is that this rewrite happens
inside the block: the handle opens and closes right
there, so the State effect does not leak into the function’s
signature. A function with a var inside has the same
signature it would have without it.
More visible mutations — writing to an array that lives beyond
the block, sending to another actor’s mailbox, modifying memory
observed from outside — do show up in the signature, under
effects like Mutable, Actor, or whichever fits. We’ll see
that distinction in chapter 12.
The rule I follow is simple: use let by default; if you
need a local variable that changes, var with :=;
if what you want to mutate is something visible from outside,
you are in effects territory and you’ll have to declare them.
2.3 Option and Result instead of null and exceptions
The oldest question in language design: what does a function do when it cannot return what it promised?
The C, Java, Python, JavaScript answer (and many others) is to
lie: the function says it returns a User, but in some
cases it returns a magic value called null (or None, or
nil) which is not a user, and which the type system does
not distinguish from a real one. The caller has to remember to
check. Tony Hoare, who invented the null reference back in
1965, called that decision his “billion-dollar mistake”.
The other traditional answer is to throw an exception. The
function does not return anything and, without telling the type
system, it diverts control to some distant catch. Which
catch? Depends. Sometimes there isn’t one and the program
dies.
kaikai picks a third path, old in the ML family but still uncommon outside of it: encode the possibility of failure in the return type.
type Option[a] = None | Some(a)
type Result[a, e] = Ok(a) | Err(e)
A function that may not find its result returns
Option[User]: either Some(usr) when found, or None
otherwise. A function that may fail in several ways returns
Result[User, Error]: either Ok(usr), or Err(reason). In
both cases, the type forces you to consider both
possibilities.
Compare:
# Python: the caller has to remember
def find(id: int) -> User:
... # sometimes returns None, sometimes not, check the docs
usr = find(7)
print(usr.name) # crash if usr is None
# kaikai: the type says so
fn find(id: Int) : Option[User] = ...
let r = find(7)
match r {
Some(usr) -> println(usr.name)
None -> println("not found")
}
In the kaikai version, match is exhaustive: if you forget the
None branch, it does not compile. The compiler reminds you
of what Python leaves to your memory.
What about exceptions? kaikai has an equivalent mechanism — the algebraic effects of chapter 12, which let you declare an aborting effect in three lines — but there too, what can fail appears in the type. The “invisible exceptions” that in Java or Python can spring out of any call do not exist in kaikai. If a function can jump elsewhere, its signature says so.
This changes how programming feels. Instead of wrapping every
external call in a try just in case, you read the signature,
see what can fail, and decide right there what to do.
A note on !
In kaikai, the postfix ! operator applies to an Option or
a Result and propagates the negative case: if the value is
Ok(v) or Some(v), the expression evaluates to v and the
program continues; if it is Err(e) or None, the current
function returns right there with that Err or None,
handing it to the caller.
fn load() : Result[User, Error] {
let id = parse_id(input)! # if it fails, propagate
let data = read_file(id)! # likewise
Ok(build_user(id, data))
}
This is the same as Rust’s ?. And it is worth saying what it
isn’t: in Elixir, naming a function File.read! is a
convention for the version that raises an exception instead
of returning an {:ok, _} | {:error, _} tuple. In kaikai the
convention is the opposite: ! is not part of a name, it is an
operator, and it never raises. It only unpacks the happy
case and propagates the negative one through the return type.
If you have seen plenty of File.read! in Elixir code and it
made you nervous, you can relax: in kaikai that same syntax is
on the safe side.
2.4 Pattern matching as a control-flow tool
In an imperative language, deciding what to do based on the shape of a value usually takes three steps:
- Check the shape with
if,instanceof,is,typeof, or a discriminator field. - Get at the data carried by that shape, with casts,
as, or named accesses that assume step 1 worked. - Do something with that data.
kaikai folds the three into a single construct: match.
match expr {
Lit(n) -> n
Add(l, r) -> eval(l) + eval(r)
Mul(l, r) -> eval(l) * eval(r)
Neg(x) -> -eval(x)
}
Each arm is a pattern followed by the expression that
pattern produces. Lit(n) doesn’t only say “this was built
with Lit”; it also declares that n is the Int carried
inside, ready to use on the right-hand side. Check, unpack,
bind — in one move.
Patterns nest. If you have an Option[Result[Int, Error]] and
you want to distinguish the three possible shapes, you write:
match x {
None -> "not present"
Some(Err(reason)) -> "failed: " ++ reason
Some(Ok(value)) -> "ok: " ++ int_to_string(value)
}
Patterns can also carry guards — extra conditions evaluated
after the structural match — and wildcards (_) when you don’t
care about the value.
The crucial part: the compiler checks exhaustiveness. If
Tag has four constructors and your match covers three, it
does not compile. If you add a fifth constructor to a type and
there are thirty match sites in the codebase, those thirty
sites become compile errors that tell you exactly where to come
back. This turns a feared refactor into a tedious but safe one.
Without pattern matching, languages handle this case with
visitor patterns, class hierarchies, or chains of if / else if / else. Each works, but all of them lose the link
match keeps between the type of the value and the way you
decide on it.
After a few weeks with kaikai, match becomes one of those
tools you don’t want to give up.
2.5 Pure functions and visible effects
The four ideas above converge on a bigger one, which is the language’s central bet: separate the pure from what touches the world, and have the type system police that distinction.
A pure function is a function whose result depends only on its arguments. Calling it with the same arguments always returns the same value. It does not print. It does not read from disk. It does not send messages. It does not look at a clock. It does not roll a die.
Pure functions are easy to test, easy to reason about, easy to parallelize, easy to cache. The catch is that a program made only of pure functions does nothing useful: it never talks to the world.
kaikai puts effects in the type system. You saw this in chapter
1: a function that writes to stdout says : Unit / Stdout in
its signature. A function that uses Log says
: ... / Log. A function that may fail and bail out says
: ... / Fail. And a pure function, one that does not touch
the world, says nothing after the /. Its signature is just
fn f(x: Int) : Int.
The effect on the right-hand side of / is not decoration. It
is a constraint: the compiler will not let you call an effectful
function from a context where those effects are not being
handled. Some handler up the stack has to take ownership.
This solves, all at once and at the language level, several
old itches:
- The invisible exceptions that Java and Python permit because no signature declares them.
- The cancellation handling that in
async/awaitlanguages becomes a thread of plumbing inside the business logic, rather than a separate mechanism. - The dependency injection that in OO languages requires whole containers to do what an effect handler does in five lines.
- Logging, config access, the clock, the database: every dependency that traditionally sneaks in hidden can be an effect, declared in the type, and chosen by the caller.
If you’ve never seen this before, it sounds too ambitious to be
true. It is and it isn’t. Chapter 12 gives it the space it
deserves. For now, it is enough to know that the signatures you
see with / something are not noise: they are information
about what that function can do to your program.
2.6 The type is not the only label
One last habit worth installing early, because it reorders how you read everything that follows. In the languages you come from, the compiler reasons about a single class of label: the type. Everything else that matters about a value — what unit it is in, what can fail while computing it, who owns its memory — lives in comments, in naming conventions, or in your head.
In kaikai, the type is one label among several. The previous
section just showed you the second: effects, which live in
the signature after the / and which the compiler combines
and checks with rules of their own. Further on you will meet
others: units of measure that multiply and cancel the way
they do in physics (ch. 10), currencies that add but
refuse to multiply with each other, memory regions that
are never mistaken for one another (ch. 13). Each family has
its own algebra — its own rules for what combines with what —
and the compiler enforces it with the same rigor it applies to
types. Each of these families is what the language calls a
kind.
You don’t need the mechanism yet; chapter 19 walks through all of it, names included. What is worth taking from here is the reading habit: when kaikai rejects a program that “looked fine”, the question is not just what type it expected but which family of labels is in play. Half the time the compiler is not correcting a type: it is telling you that you mixed meters with seconds, dollars with euros, or one region’s memory with another’s. That is information that in other languages existed nowhere at all.
2.7 A short genealogy
kaikai did not appear from nowhere. It inherits decisions from several language families, and it helps to know which ones to understand why things look the way they do. None of these ideas are mine. What I chose was which ones to put together.
- From ML (1973), OCaml, and Haskell come algebraic data
types, pattern matching, Hindley-Milner type inference, and
Option/Result. Old, good ideas that “modern” languages like Rust and Swift now rediscover without always acknowledging their grandparents. - From Erlang (1986) and Elixir come isolated processes
with private memory, the idea that concurrency is built on
passing messages rather than sharing memory, and the
supervision model with
linkandmonitor. In kaikai those processes are called fibers and actors. - From Elixir also comes the pipe operator
|>we saw earlier. - From Koka (Daan Leijen, Microsoft Research) and Effekt (Andreas Rossberg, Jonathan Brachthäuser) come algebraic effects with effect rows in the type system. kaikai follows Effekt closely on how handlers are expressed, and Koka on some internal decisions.
- From Koka also comes Perceus, the compile-time-optimized reference counting scheme kaikai uses to manage memory without a garbage collector or a borrow checker.
- From Go kaikai takes the decision to ship a single binary as compiler, formatter, and test runner; the primacy of programs that build and run fast; and the discipline of keeping few forms in the language.
- From Rust, kaikai learns what not to do: sum types and pattern matching are lessons Rust teaches well. The borrow checker, on the other hand, kaikai prefers to avoid — Perceus plus isolated fibers solve the problem without asking the programmer to internalize lifetimes.
None of these decisions is new. What kaikai attempts is a coherent combination: algebraic types + algebraic effects + Perceus + BEAM-style fibers, in a language that compiles fast to native code and that an experienced programmer can read without taking a course first.
The rest of the book digs into each of those decisions. If you made it this far, you have the map.
Exercises
2.1. Go back to 02_fizzbuzz.kai from chapter 1. Identify
all the expressions that return a value. How many are
there? Are there any strict statements — something that
runs for its side effect without producing anything useful —
beyond the calls to println?
2.2. In your day-to-day language, write a short version of
the following: a function classify_age(n) that returns the
string "child", "young", "adult", or "senior" based on
ranges. How many local variables did you use? How many
assignments? Now rewrite it in pseudo-kaikai (it doesn’t have
to compile) using if as an expression.
2.3. Find a function in your work codebase that returns
null or throws an exception to signal “no result”. Write down
in a comment what its signature would be in kaikai using
Option or Result. What do you gain? What do you lose?