Maybe — Modeling Absence
When retrieving values from maps, database queries, or optional properties, standard TypeScript
represents absence with T | null or T | undefined.
While familiar, handling nullable values across multi-step transformations requires repetitive if
checks or optional chaining that breaks linear pipeline composition.
The problem with null
Section titled “The problem with null”In standard TypeScript, each transformation step requires guarding against missing intermediate values.
The Maybe Approach
Section titled “The Maybe Approach”Maybe<A> represents optionality as a discriminated union: Some<A> (value present) or None
(empty).
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 from.nullable:
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 from.Predicate
Section titled “Narrowing focus with filter and from.Predicate”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 from.Predicate 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 to.Result. 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 from.Result:
Swapping container contexts: transposeResult
Section titled “Swapping container contexts: transposeResult”When working with optional operations that return fallible results (such as Maybe<Result<E, A>>),
Maybe.transposeResult swaps the outer and inner contexts, producing Result<E, Maybe<A>>:
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:
Problems it solves
Section titled “Problems it solves”- Deeply nested payload extraction: In client applications and API consumers, parsing responses
often requires drilling through multiple layers of optional data (such as
user?.profile?.preferences?.theme). When an intermediate property is absent, optional chaining yields an ambiguousundefinedthat easily leaks into downstream computations.Maybeturns optional paths into composable values that can be mapped, filtered, and transformed without manual null checks at each layer. - Multi-step lookup and filter pipelines: When querying in-memory caches, searching array
elements by criteria, or looking up record keys, missing entries are standard domain occurrences.
Wrapping lookups in
Maybeallows subsequent processing steps (such as formatting display values, calculating tax rates, or applying discounts) to chain linearly, automatically bypassing downstream steps when an item is missing. - Combining multiple independent optional inputs (
Maybe.struct): In search panels and report generators, an operation often requires multiple optional parameters (such asstartDate,endDate, andregionId) to all be present before executing a query.Maybe.structcombines an object of individualMaybefields into a singleMaybe<Record>, resolving toSomeonly if every required field is present and short-circuiting on the firstNone. - Filtering missing entries from collection streams (
Maybe.compact): When resolving an array of identifiers against a local cache or search index, some IDs match while others yieldNone.Arr.compactandMaybecombinators filter out all absent entries and unwrap the present values into a dense array in a single point-free step. - Explicit public module contracts: In shared domain services and library interfaces, returning
nullorundefinedpermits callers to inadvertently ignore missing values until a runtime error triggers downstream. ReturningMaybe<A>makes absence an explicit type guarantee, ensuring callers handle both presence and absence at compile time.