Esc
Start typing to search...
Back to Blog

Maybe and Result: Taming Missing Data and Errors at Compile Time

2026-03-09 Keel Team 16 min read
typeslanguage-designdata-scienceerror-handlingpattern-matchingpythonRjulia

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 it

Maybe 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 it

Both 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 it

Pattern matching works the same way:

let parseResult = Ok 42
case parseResult of
    Ok n    -> n + 1        -- 43
    Err msg -> 0            -- handle the error
Try it

Result 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 it

There 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 it

The 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 it

String.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 it

The 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 it

And 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 it

This 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 it
case Err "error" of
    Ok _    -> "success"
    Err msg -> msg
-- "error"
Try it

This 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 it

Three 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 it

Change 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 it

You 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 it

And 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 it

In 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 it
let r: Result Int Int = Err 10
case r of
    Ok x | Err x -> x
    _ -> 0
-- 10
Try it

The 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.

FunctionReturn TypeWhy
List.headMaybe aThe list might be empty
List.lastMaybe aThe list might be empty
List.nthMaybe aThe index might be out of bounds
List.findMaybe aNo element might match
List.maximumMaybe aThe list might be empty
List.minimumMaybe aThe list might be empty
String.toIntMaybe IntThe string might not parse
String.toFloatMaybe FloatThe string might not parse
String.charAtMaybe StringThe index might be out of bounds
IO.readFileResult String StringThe file might not exist
IO.writeFileResult Unit StringThe write might fail
IO.parentDirMaybe StringThe path might be a root
IO.getEnvMaybe StringThe 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

FeaturePythonRJuliaKeel
Null representationNoneNULL, NA, NaNnothing, missingNothing (Maybe)
Error representationExceptionsConditions/tryCatchExceptionsErr (Result)
Absence in typesOptional[T] (advisory)NoUnion{T, Nothing}Maybe T (enforced)
Compiler enforcementNoNoPartialYes
Exhaustiveness checkingNoNoNoYes
Silent null propagationYesYes (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:

  • map and andThen for both Maybe and Result: Chain operations without manual pattern matching at each step. Just 5 |> Maybe.map (|x| x * 2) returns Just 10. Ok 5 |> Result.andThen (|x| if x > 0 then Ok (x * 2) else Err "negative") returns Ok 10.
  • withDefault: Nothing |> Maybe.withDefault 0 returns 0. Err "oops" |> Result.withDefault 0 returns 0.
  • 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 more na.rm = TRUE flags. 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 it

Watch 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.