Types Without the Typing: How Keel Gets Out of Your Way
Types Without the Typing: How Keel Gets Out of Your Way
I have a confession. When I started building Keel, I didn't set out to build a type system. I set out to build a language for data pipelines. The type system was supposed to be a weekend project.
That was eighteen months ago. The type system is still not "done." But it does something I care about deeply: it catches the bugs I used to find at 3 AM, and it does it without making me annotate every other line. Getting that balance right -- safety without ceremony -- has been the hardest and most rewarding part of the whole project.
Let me show you where we landed.
The Pitch: Write Like Python, Catch Bugs Like Haskell
Here's what Keel code looks like on the surface:
let x = 42
let name = "Alice"
let ratio = 3.14
let active = True
Try itNo type annotations. Looks a lot like Python. But under the hood, the compiler knows that x is an Int, name is a String, ratio is a Float, and active is a Bool. It figured all of that out from the values you assigned. If you later try to do x ++ name (concatenating an integer with a string), you get a compile-time error, not a runtime surprise.
The same inference flows through arithmetic:
let sum = 1 + 2 -- Int
let mixed = 1 + 2.5 -- Float (Int promoted automatically)
Try itWhen you mix Int and Float, Keel promotes the integer to a float. This was a deliberate trade-off. Forcing explicit toFloat calls everywhere would be technically purer, but in data transformation code you're constantly mixing integer counts with floating-point measurements. Requiring a conversion on every other line felt like busywork, so I chose convenience and accepted the trade-off. You might disagree, and that's fair.
Collections work the same way:
let nums = [1, 2, 3] -- List Int
let pairs = [(1, "a"), (2, "b")] -- List (Int, String)
Try itThe compiler tracks the type of every expression. You don't see it, but it's there -- ready to catch the moment you pass a String where an Int belongs.
The One Place You Have to Be Explicit
Every function requires a type signature:
fn add : Int -> Int -> Int
fn add x y = x + y
fn greet : String -> String -> String
fn greet greeting name = greeting ++ " " ++ name
Try itIf you've seen Haskell or ML-family syntax, the arrow notation reads naturally: Int -> Int -> Int means "takes an Int, then another Int, returns an Int." Functions are curried all the way down -- every function technically takes one argument and returns either a value or another function waiting for more.
Why require signatures here when everything else is inferred? I tried making them optional early on. It was genuinely bad. I'd change a function's internals, not realize the return type had shifted, and then spend twenty minutes tracking down a type error three calls downstream. Function signatures are the API boundary of your code -- the contract between you and everyone who calls your function. Making them explicit turned out to be the sweet spot.
This also unlocks function overloading. Same name, different types, resolved at compile time:
fn inc : Int -> Int
fn inc x = x + 1
fn inc : Float -> Float
fn inc x = x + 1.0
inc 5 -- 6
inc 5.5 -- 6.5
Try itNo runtime dispatch, no isinstance checks. The compiler picks the right implementation based on the argument type. Adding overloading complicated the type checker quite a bit (I underestimated it by about two weeks), but it pays for itself in real code where you handle both integer IDs and floating-point measurements.
Partial application falls out naturally from currying:
let hello = greet "Hello"
hello "World" -- "Hello World"
Try itMaking Illegal States Unrepresentable
Keel's enums carry data, and the compiler forces you to deal with every variant:
type Direction = North | South | East | West
type Shape
= Circle(Float)
| Rectangle(Float, Float)
Try itA Shape is either a Circle with a radius or a Rectangle with width and height. There's no third option. The type defines exactly what's possible, and nothing else. When you pattern match on a shape, the compiler checks that you handled everything:
case shape of
Circle r -> 3.14 * r * r
-- Warning: Non-exhaustive pattern match: missing variants: Rectangle
Try itAdd a new variant to an enum? The compiler shows you every case expression that needs updating. No grepping, no hoping. In a Python codebase, you'd be searching for string comparisons and crossing your fingers that you found them all. I've shipped that bug more times than I'd like to admit, which is partly why I built this.
Enums can also carry record data, which is useful for modeling domain concepts:
type User
= Guest
| Member { name: String, id: Int }
Try itAnd you construct them with qualified syntax:
let c = Shape::Circle(2.0)
let d = Direction::North
let u = User::Member { name = "Alice", id = 42 }
Try itNo Null. Not Even a Little.
Keel has no null, no nil, no None-that-silently-eats-your-pipeline. Optional values use Maybe, and you must handle both cases:
let m = Just 42
case m of
Just x -> x + 1 -- 43
Nothing -> 0 -- you must handle this
Try itFallible operations use Result:
let parseResult = Ok 42
case parseResult of
Ok n -> n + 1 -- 43
Err msg -> 0 -- handle the error
Try itIf you instinctively reach for habits from other languages, the compiler catches you and redirects:
let x = null
-- Error: 'null' is not a Keel keyword
-- Hint: Use `Nothing` for absent values
-- Note: Keel uses Maybe types to represent optional values safely.
Try itEvery single error variant in the compiler -- all 115 of them -- has a contextual hint and a note. Building those messages was, frankly, a slog that took way longer than I expected. But a type error you can't understand is barely better than no type error at all. We go deeper into how Maybe and Result work in the dedicated post, and the error message philosophy comes up in the parsing post.
Type Aliases: Naming Your Domain
When you're working with complex data, naming your types makes things self-documenting:
type alias Name = String
type alias Age = Int
type alias Person = { name: String, age: Int }
type alias Predicate a = a -> Bool
Try itAliases resolve fully during type checking -- a Name is a String, no ceremony:
type alias UserId = Int
let id: UserId = 42
let doubled: Int = id * 2 -- works: UserId is just Int
Try itThere's a reasonable argument that these should be opaque newtypes so you can't accidentally add a UserId to a Temperature. That's on the roadmap. For now, aliases are a lightweight way to give meaning to your data. Good enough for today, better thing planned for tomorrow.
Records: Where Data Lives
Records are how you model structured data:
let person = { name = "Alice", age = 30 }
person.name -- "Alice"
person.age -- 30
Try itFunctional record updates create a new record, leaving the original untouched:
let older = { person | age = 31 }
-- older is { name = "Alice", age = 31 }
-- person is still { name = "Alice", age = 30 }
Try itDestructuring requires you to be explicit about what you're ignoring:
let { name, age } = person -- OK: all fields matched
let { name, .. } = person -- OK: explicitly ignores the rest
let { name } = person -- Error: missing fields: age
Try itThat .. requirement catches a real class of bugs. When someone adds an email field to the record, every destructuring pattern that doesn't use .. fails to compile. You're forced to decide: do I need this new field or not? I agonized over whether this was too strict, but every time I've been bitten by a "forgot to handle a new field" bug in another language, I feel better about the decision.
This works in case expressions too:
let user = { name = "Carol", age = 35, email = "carol@example.com" }
case user of
{ name, .. } -> "Hello, " ++ name
Try itLambdas: Types Flow Through Pipes
In data pipelines -- Keel's bread and butter -- lambda parameter types are inferred from context:
import List
[1, 2, 3] |> List.map (|x| x * 2) -- [2, 4, 6]
[1, 2, 3, 4] |> List.foldl (|acc x| acc + x) 0 -- 10
Try itThe compiler knows x is an Int because it can see the list contains integers. It knows acc is an Int because it can see the initial value 0 and the function signature of foldl. No annotations needed.
When there's no context -- a standalone lambda -- you do need to annotate:
let f = |x: Int| x + 1
f 5 -- 6
Try itLambdas support multi-parameter, closures, and even record destructuring:
let multiplier = 3
let mult = |x: Int| x * multiplier
mult 5 -- 15
let describe = |{ name: String, age: Int }| name
describe { name = "Alice", age = 30 } -- "Alice"
Try itKeel uses |x| for lambda syntax rather than \x ->. It reads better in pipe chains, which is where most lambdas live. I wish I could say this was some brilliant insight. Really I just thought the backslash looked ugly in pipes and tried alternatives until something stuck.
The pipe operator also works with standard library functions directly:
import String
import List
"hello" |> String.toUpper |> String.length -- 5
[1, 2, 3] |> List.reverse |> List.length -- 3
Try itWhen You Get It Wrong
The compiler doesn't just say "type error." It tells you what went wrong and what to do about it:
fn add : Int -> Int -> Int
fn add x y = x + y
add "hello" 5
-- Error: Type mismatch: expected Int, found String
Try itMisspell a variable? Fuzzy matching suggests corrections:
let userName = "Alice"
print usrName
-- Error: Variable 'usrName' is not declared.
-- Did you mean 'userName'?
Try itThe fuzzy matching uses a weighted combination of Jaro-Winkler similarity and Levenshtein distance, with a bonus for matching prefixes. Overkill? Probably. But it feels great when it catches your typos, and that's reason enough.
Use syntax from another language by accident?
{ name: "Alice" }
-- Error: JavaScript-style record syntax is not allowed
-- Hint: Use `=` instead of `:` for record field assignment.
Try itreturn 5
-- Hint: Keel is expression-based; the last expression is the return value
Try itmatch x of
-- Hint: Use `case ... of` for pattern matching
Try itFriendly compiler errors are a hill I was willing to die on. I've spent too many hours staring at expected <T12> but found <T14> in other languages. The first time someone says "the compiler told me exactly what to fix" -- that's the whole point.
How the Pieces Connect
The type system isn't isolated -- it's woven through every phase of the compiler. Types flow through parsing, where the parser tracks scope to catch undeclared variables and suggest corrections on the spot. They guide code generation, where the compiler selects the right instruction variants for different types. And they're validated by the exhaustiveness checker, which makes sure your pattern matching covers every case.
If you're curious about the internal machinery -- the Type enum with its seventeen variants, the unification algorithm, how lambda inference actually works step by step -- we wrote a whole separate post about it: Inside Keel's Type Checker.
The implementation language matters here too. As we discuss in Why Keel Is Written in Rust, Rust's own type system gives us the same exhaustiveness guarantees we give to Keel's users. When we add a new type to Keel, the Rust compiler shows us every place in the implementation that needs updating. It's types all the way down, and honestly, that recursion is deeply satisfying.
The Honest Trade-offs
I don't want to pretend the type system is perfect. It isn't. Here's the honest accounting:
Type inference is bottom-up, not constraint-based. Some programs that would type-check in Haskell or OCaml need explicit annotations in Keel. The upside is that inference is simple to reason about -- when the compiler can't figure something out, you can usually see why. The downside is the occasional "come on, can't you just figure this out?" moment.
Type aliases are transparent, not opaque. A UserId and a plain Int are interchangeable. This means you can accidentally add a UserId to a Temperature and the compiler won't bat an eye. Newtypes are on the roadmap.
No type classes yet. Ad-hoc polymorphism lives on a wish list. Function overloading covers the most common cases, but it's less elegant than what Haskell or Rust offers. We'll get there.
These are scope decisions, not accidents. The current system handles the patterns that matter for data transformation code, and I'd rather ship something that works well for its use cases than something theoretically complete but confusing in practice.
Why This Matters for Real Pipelines
A data pipeline with 50 transformations in Python is a liability. Not because the logic is wrong -- the logic is usually fine when you write it. But because when someone renames a field in step 3, steps 14 through 20 might silently produce garbage. Tests only cover the cases you thought of.
The same pipeline in Keel won't compile if the types don't line up. Every function signature is a checked contract. Every record destructuring accounts for all fields (or explicitly opts out with ..). Every enum case expression handles every variant. When you change something, the compiler tells you everything that broke.
That's not a theoretical benefit. That's the difference between pushing a change at 5 PM on a Friday and going home, versus pushing a change and getting paged at midnight. I've lived both versions. I strongly prefer the first one.
What's Next
The type system covers a lot of ground today, but it's far from done. Type classes are on the roadmap. The long-term dream is typed dataframes and matrix dimensions -- imagine the compiler verifying that your transformations preserve the right columns. That's ambitious enough to be scary, which probably means it's worth doing.
If you want to try it, head to the playground or the getting started guide. And if a type error message confuses you, that's a bug -- let us know.