Maybe — Modeling Absence
In modern software development, we spend a massive amount of time dealing with things that are not there. We query a database for a record that might have been deleted; we look up a key in a configuration file that might be missing; we search a list for an item that is not present.
In traditional TypeScript, we typically represent this nothingness with null or undefined. On
the surface, this approach seems simple. But as systems grow, we find that it introduces a subtle,
persistent friction: it intertwines the logic of what we want to do with a value and the defensive
checking of whether that value even exists.
The problem with null
Section titled “The problem with null”Consider a simple, everyday snippet of TypeScript:
On the surface, this is familiar and easy to write. But notice the implicit coupling. The caller of
getUser cannot simply ask for the user’s name; they must first stop, pivot their attention, and
inspect the pointer. If they forget, strict mode might warn them, but the responsibility of the
check still lives in the runtime flow of their code.
When we have a pipeline of operations — say, taking a configuration setting, parsing it into a number, and then checking if it is within a valid range — this defensive checking must be repeated at every step. The happy path of our logic and the recovery path of handling absence are twisted together. They are complected.
This code is hard to read not because the math is complicated, but because the business logic (parsing and bounds validation) is constantly interrupted by guards against nothingness.
The Maybe approach
Section titled “The Maybe approach”What if we could separate these concerns? What if absence was not a missing pointer or a blank space, but a first-class value in its own right? A value that carries its own rules for transformation.
This is the purpose of Maybe. It is a data structure that represents a choice: either we have
something (Some), or we have nothing (None).
flowchart TD
Input([Raw Data]) --> Choice{Is it null or undefined?}
Choice -->|No| Some[Some A]
Choice -->|Yes| None[None]
Some --> Transform[Apply Transformations]
None --> Skip[Skip Transformations]
Transform --> Edge[Extract with Default / Match]
Skip --> Edge
By representing absence as a data structure rather than a language keyword, we can write our transformations as if the value is always there. The data structure itself manages the control flow. We decouple the what from the if.
Creating Maybe
Section titled “Creating Maybe”To work within this model, we must first lift our raw, potentially unsafe values into a Maybe
context. This is the boundary where the messy real world meets our clean system.
In practice, you will rarely write some or none manually. Instead, you will ingest values coming
from external interfaces — such as third-party libraries, DOM elements, or API payloads — which use
null or undefined. For this, we use fromNullable:
Transforming values
Section titled “Transforming values”Once our data is wrapped inside a Maybe, we do not immediately unpack it. Instead, we describe how
the value should change if it is present.
Pure transformations with map
Section titled “Pure transformations with map”If we have a Some, map applies a function to the value inside and returns a new Some with the
result. If we have a None, map does nothing and returns the None unchanged.
Consider how this simplifies deep record navigation. Imagine parsing a user profile to extract their avatar’s filename:
We do not write a single if statement. If profile is missing, the first map is skipped. If
avatarUrl is missing, the second map is skipped. The pipeline remains perfectly linear, and we
are guaranteed never to encounter a TypeError: Cannot read properties of undefined.
Nested pipelines with chain
Section titled “Nested pipelines with chain”Sometimes, a transformation itself might return a Maybe. For example, we might want to take a
string and parse it into an integer. The parsing operation is fallible — if the string is "abc",
there is no valid number to return.
If we were to use map with a function that returns a Maybe, we would end up with a nested type:
Maybe<Maybe<number>>. This is inconvenient to work with.
To resolve this, we use chain. It applies the transformation and flattens the nested structure,
leaving us with a clean Maybe<number>.
Think of map as a tool for transformations that are guaranteed to succeed once a value is present,
and chain as a tool for transformations that themselves introduce the possibility of failure.
Narrowing focus with filter and fromPredicate
Section titled “Narrowing focus with filter and fromPredicate”Sometimes a value exists, but it does not meet our business criteria. We can use filter to turn a
Some into a None if it fails to satisfy a predicate.
If we are starting from a raw value rather than a Maybe, we can use fromPredicate to decide
whether to wrap the value in Some or None right at the boundary:
Extracting the value
Section titled “Extracting the value”Eventually, our pipeline must interface with the rest of our application — libraries that expect standard TypeScript primitives, UI frameworks, or database connectors. We must step out of our philosophical data structures and return a concrete value. We call this reaching the edge.
Safe fallbacks with getOrElse
Section titled “Safe fallbacks with getOrElse”The most common exit point is getOrElse. It extracts the value from a Some, or returns a
fallback value if we have a None.
Notice that getOrElse takes a function — a thunk — rather than a direct value. This is a
deliberate design choice. If computing the fallback is expensive or triggers a side effect (such as
writing to a log or generating a token), that computation is deferred. It is only executed if the
value is actually absent.
Case analysis with match and fold
Section titled “Case analysis with match and fold”When you need to perform different logic for both the success and failure cases, you can use match
for a named object syntax, or fold for a positional, error-first syntax.
Interoperability with standard TypeScript
Section titled “Interoperability with standard TypeScript”If you are passing the result to an external library that expects standard null or undefined
values, you can convert the Maybe back at the very end of your pipeline:
Side effects with tap
Section titled “Side effects with tap”Occasionally, you need to perform a side effect — such as writing a message to a log or updating a
metric — without altering the data or breaking the flow of your pipeline. For this, we use tap.
tap runs a side-effectful function on the value inside a Some and returns the original Maybe
unchanged. If the Maybe is None, the function is ignored.
Recovering from None
Section titled “Recovering from None”When an operation fails to produce a value, we don’t always want to settle for a static fallback. Often, we want to try a secondary, fallible strategy. For instance, if a config is not in our memory cache, we might want to look for it in the database.
For this, we use recover. It takes a function that returns another Maybe when the current one is
None.
If the cache hit succeeds, the database lookup is never evaluated. If the cache misses, we attempt
the database lookup. Only if both fail do we settle for the fallback value "light".
Converting to and from Result
Section titled “Converting to and from Result”A Maybe represents absence but does not tell you why the value is missing. Sometimes, absence is
an error, and we need to attach a reason to it.
We can transition from a Maybe to a Result using toResult. We provide a thunk that generates
an error value if the Maybe is a None:
Conversely, if we have a Result and want to discard the error context, we can downgrade it to a
Maybe using fromResult:
Composition in practice
Section titled “Composition in practice”Let us look at how these elements fit together into a cohesive, structured pipeline. We will take a raw query parameter representing a page offset, validate it, apply pagination bounds, and return a clean offset index.
Observe the flow. Every step is insulated. The parsing logic, the validation criteria, and the
mathematical mapping are written in a straight, readable line, fully detached from the defensive
logic of verifying the presence of rawOffset.
Accumulating values: bind / bindTo
Section titled “Accumulating values: bind / bindTo”When you need to perform multiple sequential operations and gather their results into a single object, traditional pipelines can become deeply nested because each successive function needs access to previous results:
To solve this, we can use bindTo and bind to cleanly accumulate values key-by-key in a flat,
readable pipeline.
bindTo lifts a value into the pipeline’s accumulator object:
bind runs a new operation using the accumulated object and attaches the result to a new key:
If any step fails (yielding None), the entire pipeline short-circuits and propagates None
immediately.
Combining records: struct
Section titled “Combining records: struct”While bind is perfect for sequential steps where a latter step depends on the output of a prior
step, sometimes you have a set of independent Maybe values that you want to combine into a single
object. For this, you can use Maybe.struct.
It combines a record of Maybe values into a single Maybe holding a record of success values. If
any individual field is None, the entire struct short-circuits to None immediately:
If multiple fields are None, Maybe.struct short-circuits immediately on the first None
encountered in key order:
When to use Maybe vs null
Section titled “When to use Maybe vs null”The introduction of Maybe into a codebase is a structural decision. It is not always the correct
choice for every line of code.
Use Maybe when:
Section titled “Use Maybe when:”- The pipeline is long: You have multiple steps of transformations, filtering, and recovery that could each result in absence.
- The type is public: You want absence to be explicitly declared in your module signatures, forcing callers to acknowledge it.
- You value composition: You want to compose operations using
pipeand clean, pure functions.
Keep using null or undefined when:
Section titled “Keep using null or undefined when:”- The scope is local: Inside a small, internal function where a simple ternary or
??operator is easier to read and has zero performance overhead. - The boundary expects it: When writing low-level wrappers directly interfacing with large
third-party libraries (e.g., database drivers) that heavily rely on
nullreturns. Convert toMaybeonly once the data passes this boundary.