Maybe and Result: Taming Missing Data and Errors at Compile Time
Maybe and Result: Taming Missing Data and Errors at Compile Time
Here is a confession. I once spent an entire afternoon hunting a bug in a data pipeline that turned out to be a single missing value. Not a crash, not an exception -- a quiet None that slipped through a chain of transformations, infecting every number downstream. The dashboard still rendered. The charts still had bars. The bars were just wrong.
The worst part? I wrote the code. I knew the lookup could return None. I just forgot to check.
If you have worked with data in Python, R, or Julia, you have a version of this story. Maybe yours involved NA silently turning an average into NA. Maybe it involved an unhandled exception at 2 AM. The details vary; the shape of the problem does not: missing data and errors are invisible by default, and invisible things get forgotten.
Keel takes a different position. If something might be absent, the type says so. If something might fail, the type says so. And the compiler checks that you actually dealt with it. No exceptions, no special flags, no discipline required.
Let me show you what that looks like in practice.
Two Types, One Principle
The principle is: make the possibility of failure part of the type signature, so it cannot be ignored.
Keel has two types for this. Maybe represents a value that might not exist. Result represents an operation that might succeed or fail (with a reason).
Maybe: Something or Nothing
Just 42 -- a value is present: Maybe Int
Nothing -- no value: Maybe a
Try itMaybe wraps a value in Just when it exists, or uses Nothing when it does not. A plain Int can never be Nothing -- the types are distinct. If a function hands you a Maybe Int, you know the value might be absent, because the type tells you before you read a single line of implementation.
To use the value inside a Maybe, you pattern match:
let m = Just 42
case m of
Just x -> x + 1 -- x is 42, result is 43
Nothing -> 0 -- handle the absent case
Try itBoth branches are required. Skip the Nothing case and the compiler warns you:
Warning: Non-exhaustive pattern match: missing variants: Nothing
This is the same exhaustiveness checking described in the pattern matching post, applied to what is arguably the most common source of bugs in data work. It is not a lint. It is not optional. The compiler will not quietly let you forget.
Result: Success or Failure (With a Reason)
Sometimes "absent" is not enough information. You need to know why something failed. That is what Result is for:
Ok 42 -- success with a value
Err "not found" -- failure with a reason
Try itPattern matching works the same way:
let parseResult = Ok 42
case parseResult of
Ok n -> n + 1 -- 43
Err msg -> 0 -- handle the error
Try itResult carries two type parameters: one for the success value, one for the error. Ok 42 has type Result Int a (error type unconstrained until context resolves it). Err "not found" has type Result a String. The type checker unifies these as needed -- write Ok 42 in a context that expects Result Int String, and the unknown error type fills in as String automatically. More on this in the type checker internals post.
Why This Matters (The Python/R/Julia Edition)
Every language that works with data has to represent "this value might not be here." The question is how, and what happens when you forget to check.
Python: None and Optimism
Python uses None, a singleton that can appear in place of any value at any time:
def get_user_score(user_id):
user = db.find(user_id) # might return None
return user["score"] * 2 # TypeError at runtime... eventually
Python 3.10 added Optional[int] type hints. They are a real improvement -- but they are advisory. The runtime does not enforce them. You can annotate a function as returning Optional[int], have it return None, and the caller can still treat the result as a plain int without Mypy complaining (unless Mypy is set up, configured, running, and strict). It is a good idea with opt-in enforcement, which is a polite way of saying it works right up until it does not.
R: Three Flavors of Nothing
R has NULL, NA, and NaN. Each behaves differently:
mean(c(1, 2, NA)) # NA (propagates silently)
mean(c(1, 2, NA), na.rm = TRUE) # 1.5 (if you remember the flag)
NA propagation is particularly painful in data work. One missing value silently turns an entire column's aggregate into NA. The pipeline runs to completion. The results look plausible. They are wrong. You find out when someone questions the dashboard numbers, possibly in a meeting.
Julia: Better Types, Same Discipline Problem
Julia offers nothing and missing, with Union{T, Nothing} for type tracking:
1 + nothing # MethodError (at least it crashes)
1 + missing # missing (propagates silently, like R's NA)
Julia's type system can track optionality, which is a real step forward. But the checks are not enforced at compile time. You can still forget to handle the nothing case, and the compiler will not stop you.
Keel: The Type Is the Contract
In Keel, the return type tells the whole story:
let m = Just 42
case m of
Just x -> x
Nothing -> 0
Try itThere is no way to use a Maybe Int as an Int without unwrapping it first. There is no way to unwrap it without handling both cases. The compiler enforces this at compile time, using the same exhaustiveness machinery that checks custom enum patterns. You do not need discipline. You need a type signature.
Real Code: What Maybe and Result Look Like in Practice
Let me walk through some patterns that come up constantly in data work.
Handling a Lookup That Might Fail
let m = Nothing
case m of
Just x -> x + 1
Nothing -> 99
-- 99
Try itThe Nothing case is not boilerplate -- it is a decision. What do you want to happen when the value is missing? A default? An early return? An error message? Keel makes you answer the question. In Python, you might forget to ask it.
Parsing User Input
import String
let input = "42"
case String.toInt input of
Just n ->
if n > 0 then "positive: " ++ String.fromInt (n * 2)
else "non-positive"
Nothing -> "not a number"
-- "positive: 84"
Try itString.toInt returns Maybe Int, not Int. This is a small thing that prevents a large class of bugs. In Python, int("abc") throws a ValueError. In Keel, parsing failure is in the return type. You handle it or you do not compile.
Working with Result for Error Context
let x = Err "oops"
case x of
Ok v -> "got: " ++ v
Err e -> "error: " ++ e
-- "error: oops"
Try itThe error message travels with the result. No stack traces to parse, no exception handlers to remember. The Err branch gives you the reason right there in the pattern.
Guards and Maybe Together
One of the things I like about how Maybe interacts with the rest of the language is guards. You can pattern match on Just, then immediately refine with a condition:
let x = Just 10
case x of
Just n if n > 5 -> "big just"
Just n -> "small just"
Nothing -> "nothing"
-- "big just"
Try itAnd when the guard does not match:
let x = Just 3
case x of
Just n if n > 5 -> "big just"
Just n -> "small just"
Nothing -> "nothing"
-- "small just"
Try itThis is a pattern you reach for constantly in data work: "if the value exists and meets some condition, do one thing; if it exists but does not meet the condition, do another; if it is absent, do a third thing." Three outcomes, three branches, all explicit.
Inline Matching on Result
You do not always need a let binding. You can match directly on a constructed value:
case Ok 5 of
Ok value -> value
Err _ -> 0
-- 5
Try itcase Err "error" of
Ok _ -> "success"
Err msg -> msg
-- "error"
Try itThis is convenient for testing and for short expressions where naming the intermediate value would just be noise.
Nested Patterns: When Real Data Gets Layered
Real data is messy and nested. A database query might return a Result that, on success, contains a Maybe (because the row exists but the column could be null). Keel handles this with nested patterns:
import String
let result: Result (Maybe Int) String = Ok (Just 5)
case result of
Ok (Just x) -> "Success with value: " ++ String.fromInt x
Ok Nothing -> "Success but empty"
Err msg -> "Error: " ++ msg
-- "Success with value: 5"
Try itThree distinct outcomes in one expression. The type annotation Result (Maybe Int) String says: this operation can succeed with an optional Int, or fail with a String error. The pattern match covers all three cases: success with a value, success without a value, and failure. And x is automatically inferred as Int from the nested type -- no annotation needed inside the pattern. The type checker does the bookkeeping so you do not have to.
Change the value to Ok Nothing and the second branch fires:
import String
let result: Result (Maybe Int) String = Ok Nothing
case result of
Ok (Just x) -> "Success with value: " ++ String.fromInt x
Ok Nothing -> "Success but empty"
Err msg -> "Error: " ++ msg
-- "Success but empty"
Try itChange it to an error and the third branch fires:
import String
let result: Result (Maybe Int) String = Err "Something went wrong"
case result of
Ok (Just x) -> "Success with value: " ++ String.fromInt x
Ok Nothing -> "Success but empty"
Err msg -> "Error: " ++ msg
-- "Error: Something went wrong"
Try itYou can nest Maybe inside Maybe too, if your data model calls for it:
let m: Maybe (Maybe Int) = Just (Just 42)
case m of
Just (Just x) -> x -- 42
Just Nothing -> 0
Nothing -> -1
Try itAnd even three levels deep (though if you get here, maybe reconsider your data model):
let deep: Maybe (Maybe (Maybe Int)) = Just (Just (Just 7))
case deep of
Just (Just (Just x)) -> x -- 7
Just (Just Nothing) -> 1
Just Nothing -> 2
Nothing -> 3
Try itIn Python, this would be nested if x is not None checks. In R, nested is.null() calls. Both are easy to get wrong and hard to verify. In Keel, the compiler checks that you covered every combination.
Or-Patterns: When Ok and Err Carry the Same Type
Sometimes you want to extract a value regardless of whether it came from the success or failure branch. Keel's or-patterns handle this cleanly:
let r: Result Int Int = Ok 5
case r of
Ok x | Err x -> x
_ -> 0
-- 5
Try itlet r: Result Int Int = Err 10
case r of
Ok x | Err x -> x
_ -> 0
-- 10
Try itThe variable x is bound in both alternatives, so it is available in the branch body. This works because x has a consistent type (Int) in both the Ok and Err case. If the types differed, the compiler would catch it. More details on or-patterns in the pattern matching post.
The Standard Library: Maybe and Result as Convention
One decision I am genuinely proud of is using Maybe and Result consistently throughout the standard library. Any operation that might not have an answer returns Maybe. Any operation that might fail returns Result.
| Function | Return Type | Why |
|---|---|---|
List.head | Maybe a | The list might be empty |
List.last | Maybe a | The list might be empty |
List.nth | Maybe a | The index might be out of bounds |
List.find | Maybe a | No element might match |
List.maximum | Maybe a | The list might be empty |
List.minimum | Maybe a | The list might be empty |
String.toInt | Maybe Int | The string might not parse |
String.toFloat | Maybe Float | The string might not parse |
String.charAt | Maybe String | The index might be out of bounds |
IO.readFile | Result String String | The file might not exist |
IO.writeFile | Result Unit String | The write might fail |
IO.parentDir | Maybe String | The path might be a root |
IO.getEnv | Maybe String | The variable might not be set |
Compare this to Python, where list[0] on an empty list throws IndexError, int("abc") throws ValueError, and open("missing.txt") throws FileNotFoundError. In Python, you learn the failure modes by experience (or by reading docs that may or may not be up to date). In Keel, the return type is the documentation. It cannot get out of sync.
This consistency compounds. When your pipeline has 20 steps and each one uses stdlib functions, "every failure mode is in the types" versus "every failure mode is somewhere in the docs, probably" is the difference between a pipeline you can reason about and one you just hope works.
Coming From Another Language
If you bring habits from Python or JavaScript, Keel nudges you toward the right thing:
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.
let y = None
-- Hint: Use `Nothing` for absent values (Keel uses Maybe types)
Every one of Keel's 115 error variants includes a contextual hint and note. When you reach for syntax from another language, the compiler recognizes it and shows you the Keel equivalent. Writing all those error messages took longer than I want to admit -- but every time someone avoids a five-minute confusion because the error said "use Nothing" instead of just "unexpected token," it feels worth it. More on this philosophy in the parsing post.
How It Compares
| Feature | Python | R | Julia | Keel |
|---|---|---|---|---|
| Null representation | None | NULL, NA, NaN | nothing, missing | Nothing (Maybe) |
| Error representation | Exceptions | Conditions/tryCatch | Exceptions | Err (Result) |
| Absence in types | Optional[T] (advisory) | No | Union{T, Nothing} | Maybe T (enforced) |
| Compiler enforcement | No | No | Partial | Yes |
| Exhaustiveness checking | No | No | No | Yes |
| Silent null propagation | Yes | Yes (NA) | Yes (missing) | No |
The fundamental difference is not syntax. It is enforcement. Python, R, and Julia all have ways to represent missing values. None of them require you to handle them. Keel does. Not because we think we are smarter than those languages -- they made different trade-offs for different goals. But for data pipelines, where a silent None can corrupt an entire downstream analysis, we think the trade-off of a little extra explicitness is worth it.
Under the Hood
If you are curious about the internals: Maybe and Result are first-class types in Keel's type system, with dedicated variants in the Type enum:
pub enum Type {
// ...
Maybe(Box<Type>),
Result(Box<Type>, Box<Type>),
// ...
}
The type unifier reconciles them structurally:
(Maybe(inner1), Maybe(inner2)) => {
Maybe(Box::new(self.unify_types(*inner1, *inner2)))
}
(Result(ok1, err1), Result(ok2, err2)) => {
Result(
Box::new(self.unify_types(*ok1, *ok2)),
Box::new(self.unify_types(*err1, *err2)),
)
}
This means you can write Ok 42 without specifying the error type, and it unifies when context provides one. Result(Int, Unknown) and Result(Int, String) become Result(Int, String). The exhaustiveness checker treats Maybe as a two-variant enum (Just and Nothing) and Result as a two-variant enum (Ok and Err), using the same machinery that checks custom enums. No special cases in the compiler -- just the same tools applied consistently. The full story is in the type checker internals post.
The Verbosity Question
Let me address the obvious criticism: this is more verbose than if x is not None. You write case expressions. You write two branches. It takes more lines.
That is true. And the first few times, it does feel like ceremony.
Then you push a change to a data pipeline on a Friday, and the compiler catches the edge case you forgot about. You go home instead of debugging at midnight. And you start to see the verbosity differently -- not as ceremony, but as a checklist that the compiler enforces for you.
I grumbled about it myself when I first started building it. That feels like a long time ago now.
What Is Next
Maybe and Result cover the foundation: making failure explicit and checked. Since this post was written, the Result and Maybe stdlib modules have landed with exactly the functions described here:
mapandandThenfor bothMaybeandResult: Chain operations without manual pattern matching at each step.Just 5 |> Maybe.map (|x| x * 2)returnsJust 10.Ok 5 |> Result.andThen (|x| if x > 0 then Ok (x * 2) else Err "negative")returnsOk 10.withDefault:Nothing |> Maybe.withDefault 0returns0.Err "oops" |> Result.withDefault 0returns0.Result.mapError,Result.toMaybe,Result.fromMaybe: Convert between error types and between Result and Maybe.
See the Result module and Maybe module documentation for the full API.
Still on the roadmap:
- Typed dataframes with Maybe columns:
DataFrame { name: String, age: Int, email: Maybe String }where the type system knows which columns can have missing values. No morena.rm = TRUEflags. This one is ambitious, but I think it could be genuinely useful for data work.
Try It
Head to the playground and write:
let m = Just 42
case m of
Just x -> x
Try itWatch the compiler warn about the missing Nothing case. Add it. Then try writing null and see what the error message says.
That little interaction -- the compiler catching what you missed, and telling you exactly what to do about it -- is the whole idea. Not because Maybe is clever (the ML family figured this out decades ago). Because data work is too important to leave to None and hope.