Null, Errors and Enums Are One Feature
This idea came out of Phi, a hobby programming language I was building. I implemented it there and it worked well, so I wanted to share it. The ideas, the examples and the shape of this post are mine. English is not my strongest skill, so an AI wrote the prose from my notes.
Most languages have a separate feature for each way a value might not be what you expected. A missing value gets null or an Option. A failure gets a Result or an exception. A choice between fixed options gets an enum. True and false get a built-in bool. Each feature brings its own keywords and helpers: unwrap, expect, ?, try, catch, ok_or, map_err, unwrap_or, if let, match, switch. Most of these only convert a value from one feature to another, and you have to learn them all before you can write a function that reads a file.
This post describes a design where all of those are one feature. The whole idea takes three sentences. It adds no work at run time, and it is as compact in memory as hand-written C.
None of the pieces are new. Ceylon made null an ordinary union member. TypeScript narrows unions after a check. Zig has named sets of errors. Kotlin has ?: return. The idea here is that they are one feature.
- The idea
- Unit types
- Unions
- Putting values in
- Taking values out, by narrowing
- Enums
- The
oroperator - Mutable variables narrow too
- Memory layout
- Growing an error set
- One limit
- In short
The idea
A type is a set of values. A union A | B is the set of values that are in A or in B. A unit type is a type with exactly one value, and that value has the same name as the type.
So Int is the set of all whole numbers, and Int | Text is every whole number plus every piece of text. That is the whole idea. Everything below follows from it.
Unit types
A unit type is declared with only a name.
type Timeout
type Cancelled
Timeout is both the type and its only value. It takes zero bytes, because knowing the type already tells you the value.
A type is identified by its declaration, not by its name. If two libraries each declare a Timeout, they are different types, written io.Timeout and net.Timeout, and one union can hold both, as in Data | io.Timeout | net.Timeout.
If Timeout later needs to carry something, give it fields. It becomes a struct, and code that only names it keeps working.
type Timeout = { after_ms: Int }
Unions
A union is written with a bar, |, and it is a set.
fn find(key: Text) -> Data | none
This function returns a Data or none. And none is not a keyword. It is a unit type declared exactly like Timeout, in the prelude, a small standard file every program sees automatically.
type none
So an optional value is just a union that includes none. There is no Option. Booleans work the same way.
type true
type false
type bool = true | false
true and false are unit types, and bool is a name for the union of the two. Nothing about it is built in, and everything that works on unions works on bool.
Because a union is a set, it behaves like one:
- Order does not change what fits.
A | BandB | Ahold the same values, and each can be used where the other is expected. But a union keeps the order it was written in, through aliases and narrowing too, and one thing reads it, theoroperator below. - Duplicates disappear.
A | AisA. - Nesting flattens. If
E = Timeout | NotFound, thenData | EisData | Timeout | NotFound, one flat set of three. - A name is not a wrapper. A name given to a union, called an alias, is that union itself.
Putting values in
A value of any member type goes directly into a union that contains that type. Nothing wraps it. No constructor is called, so there is no Some and no Ok.
fn find(key: Text) -> Data | none {
if has(key) {
return lookup(key) // a Data, enters the union
}
return none // a none, enters the union
}
A smaller union goes into a bigger one for the same reason. Data | none fits into Data | none | Timeout, because every value of the first is also a value of the second.
An error that carries information is just a member that is a struct.
type NotFound = { path: Text }
type BadDigit = { at: Int }
fn read(path: Text) -> Text | Timeout | NotFound {
if not exists(path) {
return NotFound { path: path }
}
...
}
fn parse(text: Text) -> Int | BadDigit {
var value = 0
loop i in 0..text.len {
if not is_digit(text[i]) {
return BadDigit { at: i }
}
value = value * 10 + digit(text[i])
}
return value
}
There is no error base type, no error interface, and nothing to derive. A struct becomes an error simply by being returned where a failure happened.
A value that may be missing and may also fail is still one flat set.
fn query(key: Text) -> Data | none | Timeout
With wrapper types this is Result<Option<T>, E> or Option<Result<T, E>>. Both mean something, and you have to remember which one you have and convert between them. Here there is no nesting, so there is no question of which way to nest.
Taking values out, by narrowing
To use a value from a union, you check which member it is. is does the check, and in the branch where it passed, the compiler treats the same variable as that member’s type. This is called narrowing. No new variable is created.
let r = find(key)
if r is Data {
use(r) // r is a Data here
} else {
report(r) // r is a none here
}
is not narrows the other way, which gives the early-return style most code wants.
if r is not Data {
return 0
}
use(r) // r is a Data from here to the end of the block
match checks every member and must cover them all. If one has no arm, the program does not compile. as r names the matched value, and in each arm r has that arm’s type.
match fetch(key) as r {
Data => use(r)
Timeout => retry()
NotFound => create(r.path) // r is a NotFound here, so its field is right there
}
An arm can name several members, and an alias names all of its members, so grouping needs no extra syntax.
type LoadError = Timeout | NotFound
match r {
Data => use(r)
LoadError => log(r) // r is Timeout | NotFound here, still a union
}
A set can also be written out as a label. An else arm, always last, takes every member not yet named, and inside it the variable is narrowed to exactly those members.
match r {
Timeout | NotFound => log(r) // the same set, written out
else => use(r) // r is a Data here
}
Notice what did not happen. Nothing was unwrapped, and no inner value was copied into a new variable. The value was in the union all along, and narrowing is only the compiler confirming which member it is.
Enums
An enum is a union of unit types.
type Red
type Green
type Blue
type Color = Red | Green | Blue
When some options carry data, they are structs in the same union. Other languages call this a tagged union or a sum type.
type Circle = { r: Float }
type Rect = { w: Float, h: Float }
type Dot
type Shape = Circle | Rect | Dot
fn area(s: Shape) -> Float {
return match s {
Circle => 3.14159 * s.r * s.r
Rect => s.w * s.h
Dot => 0.0
}
}
match is an expression, so here it produces the value that area returns.
bool is the same thing, an enum of two. A match over Shape and a match over Data | Timeout | NotFound have the same form, so handling enums and handling errors is one thing to learn.
The or operator
Everything so far works on its own. One operator ties it together.
The left side of or is a union. Its first member is the wanted one, and every other member is unwanted.
- If the left value is the first member,
orreturns it and the right side never runs. - If it is any other member, the right side runs and can use that value.
That is the whole rule. It is why every function in this post lists its normal result first and its failures after it.
let port = lookup("port") or 8080
lookup returns Int | none, and Int is first. If it returns an Int, port gets it. If it returns none, port is 8080. Either way, port is an Int.
The right side can be any expression. It usually does one of three jobs, and none of them is a separate feature.
Use a fallback value. The right side is a value of the wanted type, like 8080 above.
Pass it up to the caller. The right side leaves the function and takes the unwanted value with it.
fn load(path: Text) -> Config | Timeout | NotFound {
let text = read(path) or return // read returns Text | Timeout | NotFound
...
}
or return with nothing after it returns the unwanted value as it is. It compiles only because load lists Timeout | NotFound in its own return type. Otherwise the compiler rejects the program and points at the member that has nowhere to go. There is no ?, no try and no conversion. The value already has the right type, so it is simply passed on.
return can also carry a new value. That turns a missing value into an error in one line.
type BadLine = { line: Int }
let tab = line.find('\t') or return BadLine { line: n }
find returns Int | none. none is not first, so the right side runs and returns a new error. Other languages need a helper such as ok_or_else for this. Here it is an ordinary or and an ordinary return.
Handle it here. The right side is a block that receives the unwanted value under a name you choose, already narrowed to only the unwanted members.
let cfg = load(path) or err {
match err {
Timeout => log("slow disk, using defaults")
NotFound => log("no config at", err.path) // err is a NotFound in this arm
}
return default_config()
}
In the block, err has the type Timeout | NotFound, not the full union, so a match with two arms covers everything. In the NotFound arm, err is narrowed again, so err.path is available. The block must either leave, as this one does with return, or produce a value of the wanted type. The compiler checks this.
When only one member is unwanted, there is nothing to match on. The name already has that type, fields included.
let n = parse(text) or bad {
point_at(text, bad.at) // bad is a BadDigit, no match needed
return 0
}
If the block ends with a value such as 0 instead of leaving, that value is the result.
No member is marked as an error. none, Timeout and NotFound are ordinary types. They are unwanted only because they are not first.
Because the right side is any expression, or also works with break and continue in loops.
loop {
let job = next_job() or break // Job | none, and none ends the loop
let out = run(job) or continue // Output | Timeout, and a Timeout skips it
ship(out)
}
The boolean or is this same operator. bool is true | false, and true is first. So in a or b, if a is true, that is the result and b never runs. If a is false, b runs and gives the result. Here the right side is a whole bool, not only the wanted true, so the result is a bool too.
fn exists(path: Text) -> bool
let found = exists("app.conf") or exists("default.conf")
That is the short-circuit or every language has, and nobody had to add it. Any function that returns bool works with it.
An early exit is the same thing with a right side that leaves. r is Data is a bool, so on false the right side runs. After that line, r is a Data.
r is Data or return 0
use(r)
Mutable variables narrow too
A let never changes, so narrowing it is easy. A var can be reassigned, and that is where most languages stop narrowing. They do not have to, as long as every way a variable can change is visible in the source. A narrowed var stays narrowed until one of three things happens, and each one is a line you can point at.
It is assigned. The variable takes the type of what was assigned.
var x: Int | none = lookup("port")
x = lookup("backup_port") or 0 // x is an Int from here on
use(x + 1)
Its address is taken. Something else could now write to it, so the narrowing ends.
if x is Int {
poke(&x) // narrowing ends here
use(x + 1) // error, x is Int | none again
}
A loop goes around again. At the top of a loop, the compiler combines the type from before the loop with the type at the end of the body, and widens back to the union.
This applies to local variables only. If the language has closures, capturing a var counts as taking its address. A field reached through a pointer is never narrowed. In all of these cases the fix is the same, and simple. Copy the value into a let, which cannot change, and narrow that.
let v = node.value // a copy, and a let never changes
if v is Int {
use(v + 1)
}
So a change from somewhere else always goes through a line the compiler can see, and it knows exactly when to stop trusting what it proved. Most code then never writes is at all. You assign, and the type follows.
Memory layout
Nothing here needs a heap, a garbage collector or any extra work at run time. The values are plain bytes, and checking a tag is a plain comparison.
A union is stored as its largest member plus a small number, called a tag, that records which member is present. Unit types take no space, so bool is just the tag, one byte. When the tag can hide inside the value, it does.
- Pointers. A valid pointer is never zero, so
*Node | none, a pointer to aNodeor nothing, is one machine word. Zero meansnoneand anything else is the pointer. This is known as the niche optimization, and it is not special tonone. Any member with unused bit patterns can hide the tag, so*Node | none | Timeoutis also one word if the pointer has two to spare. - Integers. An integer uses every bit pattern, so the tag cannot hide.
Int | noneneeds a separate tag plus padding to keep it aligned in memory, the same cost as a hand-written struct with apresentflag.
Growing an error set
or return compiles only when the caller’s return type includes the members being passed up. Does a new failure deep in a library then force edits to every signature above it? No, because an alias of a union is the union itself, and unions flatten.
A library names its failures once.
type ReadError = Timeout | NotFound
fn read(path: Text) -> Text | ReadError
Code above it uses the name, not the list.
fn load(path: Text) -> Config | ReadError | BadLine {
let text = read(path) or return
...
}
fn start() -> App | ReadError | BadLine {
let cfg = load("app.conf") or return
...
}
Config | ReadError | BadLine is the flat set Config | Timeout | NotFound | BadLine.
Now the library adds a failure.
type Locked = { holder: Text }
type ReadError = Timeout | NotFound | Locked
read, load and start need no edits. ReadError now has three members, so every or return on the way up already accepts a Locked. Only the code that handles failures stops compiling, which is exactly right.
let cfg = load("app.conf") or err {
match err { // error, Locked has no arm
Timeout => log("disk is slow")
NotFound => log("no config at", err.path)
BadLine => log("bad line", err.line)
}
return 1
}
The compiler names the missing member. Add one arm, Locked => log("held by", err.holder), and the program compiles again. Every handler is found for you, and code that only passed the failure up is untouched.
One limit
A union is a set, so two members of the same type become one. Int | Int is Int, and the compiler should say so plainly when it happens. If success and failure are both plain integers, a reader cannot tell them apart either, so in your own code the fix is to name the thing.
type ErrorCode = { code: Int }
fn run() -> Int | ErrorCode
Generic code is harder, because its author does not choose the type.
fn get[K, V](map: Map[K, V], key: K) -> V | none
When V is Int | none, the result is still Int | none, and the caller cannot tell a missing key from a key whose value is none. A caller who owns V can use a unit type of their own instead of none, such as Int | Unset, and the three members stay apart. The library cannot choose V, so its only fix is a wrapper.
type Found[V] = { value: V }
fn get[K, V](map: Map[K, V], key: K) -> Found[V] | none
That is a real trade. The wrapper this design removes comes back, but only where the ambiguity matters, and as an ordinary struct. Ceylon and TypeScript have the same limit and live with it.
In short
After a while you stop asking whether something is an option, a result or an enum, because they were never different things. A function returns a set, and the set after the arrow tells you everything. Data | none | Timeout says the function may produce data, may find nothing, and may time out, and nothing leaves the function any other way. You handle some members, pass some up, and replace some with a fallback, all with three words: is, match and or. The compiler tells you the moment your list of members and your handling of them disagree.
What languages add for values that might not be what you expected is one idea under several names. Take the names away and what is left is a set, a check, and or.