Unions Are Enough
This idea comes from Phi, a hobby programming language I am building.
Most languages have a separate feature for each way a value can be other than expected. Missing values get null or Option. Failures get Result or exceptions. Fixed choices get enums. True and false get a built-in bool. Each brings its own helpers (unwrap, ?, try, catch, ok_or, map_err, if let), and most of them only convert one feature into another.
Here they are all one feature. It costs nothing at run time and is as compact in memory as hand-written C. None of the pieces are new: Ceylon made null a union member, TypeScript narrows unions, Zig has error sets, Kotlin has ?: return. The idea is that they are one thing.
The idea
A type is a set of values. A union A | B is the values in A or in B. A unit type has exactly one value, with the same name as the type.
type Timeout // a type and its only value, zero bytes
type none // in the prelude, not a keyword
type true
type false
type bool = true | false
So an optional value is just T | none, and bool is just a union. A type is identified by its declaration, so io.Timeout and net.Timeout are different types. Unions behave like sets: duplicates disappear, nesting flattens, and a name given to a union is that union. Order does not change what fits, but it is kept, and the or operator below reads it.
Values in, values out
A value goes into a union as it is. There is no Some and no Ok. An error that carries data is just a struct, with no error base type and nothing to derive.
type NotFound = { path: Text }
fn read(path: Text) -> Text | Timeout | NotFound {
if not exists(path) {
return NotFound { path: path }
}
...
}
A value that may be missing and may also fail is one flat set, Data | none | Timeout, not Result<Option<T>, E> or the other way around.
To use a value, check which member it is. After if r is Data, the same variable is a Data in that branch, and is not gives the early-return style. match must cover every member or the program does not compile. An arm can name an alias to cover a group of members, and else takes the rest.
match fetch(key) as r {
Data => use(r)
Timeout => retry()
NotFound => create(r.path)
}
Nothing is unwrapped. The value was in the union all along, and narrowing is only the compiler confirming which member it is. An enum is a union of unit types, Red | Green | Blue, and a sum type is a union of structs, so handling enums and handling errors is one thing to learn.
The or operator
The first member of a union is the wanted one, and the others are unwanted. a or b gives a if it is the first member. Otherwise it runs b, which can be any expression.
let port = lookup("port") or 8080 // fallback
let text = read(path) or return // pass it up
let tab = line.find('\t') or return BadLine { line: n }
let n = parse(text) or bad { // handle it here
point_at(text, bad.at)
return 0
}
let job = next_job() or break
or return compiles only if the caller's return type lists the members being passed up. In a handler block, the name is narrowed to only the unwanted members. Nothing is marked as an error: none and Timeout are unwanted only because they are not first.
The boolean or is the same operator. bool is true | false and true is first, so short-circuiting comes for free. r is Data or return 0 is an early exit.
Mutable variables
A narrowed var stays narrowed until something visible in the source changes it: it is assigned and takes the new type, its address is taken, or a loop goes around again. Only local variables narrow. For anything else, copy it into a let and narrow that.
Memory
A union is its largest member plus a small tag. Unit types take no space, so bool is one byte. When a member has unused bit patterns the tag hides in them, so *Node | none is one machine word, with zero meaning none. Int | none uses every bit, so it needs a separate tag, the same cost as a C struct with a present flag.
Growing an error set
type ReadError = Timeout | NotFound
fn read(path: Text) -> Text | ReadError
Callers write ReadError, not the list. When the library adds Locked, every or return on the way up already accepts it, with no edits. Only the match that handles failures stops compiling, and the compiler names the missing member.
One limit
Sets merge duplicates, so Int | Int is Int. In your own code, name the thing: type ErrorCode = { code: Int }. Generic code is harder. If get returns V | none and V is Int | none, a missing key looks like a stored none. The library's fix is a wrapper, Found[V] | none, so the wrapper comes back, but only where the ambiguity matters. Ceylon and TypeScript have the same limit.
In short
A function returns a set, and its signature says everything. Data | none | Timeout may give data, nothing, or a timeout, and nothing leaves the function any other way. Three words handle all of it: is, match and or.