Loon is a Lisp

I built the programming language I've always wanted! I've attempted a few times before, but this time I had Claude to help me.

It's called Loon. Lisp Of Opinionated Nature. Square brackets, algebraic effects, pattern matching, macros, compiles to WASM. The documentation site is written in itself.

The thing that blew my mind is how a language can know every type in your program without you writing a single one. It's a 60-year-old algorithm called Algorithm W.

but first, birds.

The bird

Common loon swimming on a calm lake

A loon is a bird! Gavia immer, the common loon. If you've spent any time on a lake in northern Minnesota you've heard one. That tremolo call across still water at dusk. It doesn't sound like a bird. It sounds like something that was here before you and will be here after.

I grew up hearing them. They're on the Minnesota quarter. Every cabin up north has a loon painted on something.

They're famous for two things:

  1. That call.
  2. They can't walk.

Their legs are set so far back on their body, optimized entirely for diving, that they literally cannot walk on land. They belly-flop onto their nests. They push themselves around with their chests. On land, a loon is a disaster. In the water? Fastest diving bird in North America. 60 meters deep. Three minutes underwater. Absolutely cracked.

The name was supposed to be a joke. It stuck!

You never write a type

Loon eyes have a nictitating membrane, a transparent third eyelid that lets them see clearly underwater. Most diving birds close their eyes when they dive. Loons keep theirs open. They can see where they're going.

So can Loon's compiler. Look at this function:

[fn add [a b] [+ a b]]

No type annotations. The compiler figures out that a and b are numbers and the return is a number. Pass a string? Compile error. Most languages make you choose: write types everywhere (Rust, Java, C) or write no types and pray (Python, Ruby, JavaScript). Loon keeps its eyes open. The compiler can see where it's going.

How? An algorithm published in 1978 by Robin Milner, based on work by Roger Hindley from 1969. Algorithm W. The most elegant thing I've encountered in computer science!

Robin Milner looking surprised
Robin Milner, moments after seeing JavaScript for the first time.

Every ML, every Haskell, every Rust (under the hood) traces back to this.

The idea

Algorithm W in one sentence:

Walk the expression tree. At every node, generate fresh type variables for anything you don't know yet. When two things must be the same type, unify them. When unification fails, that's a type error.

That's it! The whole algorithm! Everything else is bookkeeping.

Walking through it

Take this Loon program:

[fn double [x] [+ x x]]
[double 5]

Algorithm W processes top-to-bottom, left-to-right. At each step it knows some things and guesses the rest.

[fn double [x] [+ x x]]
We see a parameter x. We don't know its type yet, so invent ?a.
x : ?a
1/4

We don't know what x is, so invent a fresh type variable ?a. Then + tells us ?a must be Num. Unify, and ?a clicks into place. Return type clicks too. Result: double : Num → Num. We didn't write that. The algorithm discovered it!

Loon bones are solid

Most birds have hollow bones to save weight. Loons don't! Their bones are solid. Dense, heavy. Because they need to sink. A loon that can't dive is a dead loon. They traded flight efficiency for depth.

(They can still fly, barely. They need like a quarter mile of runway. It's not graceful.)

Unification: the engine

The whole thing runs on unification! Two types go in. If they can be made equal, the substitution grows. If they can't, type error.

?a Num
?a = Num
variable binds
Num String
type error
mismatch fails
?a List ?a
infinite!
occurs check

Three rules. Variables bind to anything. Concrete types must match. And ?a can't unify with List ?a (that's infinite). The whole unification engine in Loon is about 60 lines of Rust:

fn unify(a: Type, b: Type, subst: &mut Subst) -> Result<()> {
    let a = subst.resolve(a);
    let b = subst.resolve(b);
    match (a, b) {
        (Var(v), t) | (t, Var(v)) => {
            if occurs_in(v, &t) { return Err(InfiniteType) }
            subst.bind(v, t);
            Ok(())
        }
        (Fun(a1, r1), Fun(a2, r2)) => {
            unify(a1, a2, subst)?;
            unify(r1, r2, subst)
        }
        (a, b) if a == b => Ok(()),
        (a, b) => Err(TypeMismatch(a, b))
    }
}

Simplified from types/mod.rs. The real one handles row types, trait constraints, and ADT constructors. But the core is this!

Loons mate for life

Two adult loons with a chick on a Minnesota lake

Same lake every year. Same partner, same territory, same nest site. One pair documented returning for 25 years!

I've been trying to write my own lisp for awhile. 2019. 2024. I keep coming back. Same syntax family, same core idea: code is data, data is code. I just kept wanting a version with better opinions.

(I don't know if that makes me loyal or stubborn. The loon doesn't know either.)

The magic: let-polymorphism

Here's where it gets beautiful.

[fn id [x] x]

[id 5]
[id "hello"]

id returns whatever you give it. Type: ?a → ?a. But if [id 5] locks ?a = Num, then [id "hello"] fails. Disaster!

The fix: generalization. When a binding completes, the algorithm generalizes it to ∀a. a → a. Each use gets a fresh copy. [id 5] gets ?a₁, unifies with Num. [id "hello"] gets ?a₂, unifies with String. Both correct!

Generalize on binding. Instantiate on use. Milner proved this always finds the most general type possible, without any annotations.

The 60-year-old punchline

Hindley discovered this in 1969. Milner rediscovered it in 1978. Damas formalized the proof in 1982. Damas-Hindley-Milner. Three names, one idea: walk the tree, invent variables, unify, generalize.

Not machine learning. Not heuristics. Proven to find the most general type, every time, in almost-linear time! Loon layers on row types, trait bounds, and effect types. The whole inference engine: 400 lines of Rust.

I spent weeks on the parser. Months on the interpreter. An afternoon on type inference because Milner already solved it. In 1978.

Try it

loading compiler...

Type some Loon. Watch the types appear!

An algorithm from 1978, running in WebAssembly, in your browser, figuring out what you meant.

The call

If you've ever been on a lake at dusk up north and heard a loon call, you know it doesn't sound like a bird. Sounds like something older. A frequency that belongs to the lake itself.

That's how good type inference feels! You write code. The types are just there. Because the language can see where it's going.

It can't walk. It dives.

Source · Docs