Maybe — Modeling Absence
1.56 kB minified + gzipped
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. With Maybe, the entire check collapses into a single, linear pipeline:
The Maybe Approach
Section titled “The Maybe Approach”Maybe<A> represents optionality as a discriminated union: Some<A> (value present) or None
(empty).
flowchart TB
Input(["Raw Value"]) --> Check{"Is value null or undefined?"}
Check -->|"Present (value)"| Some["Maybe.Some(A)"]
Check -->|"Absent (null/undefined)"| None["Maybe.None"]
Some -->|"Maybe.map / Maybe.chain"| Step2["Next Transformation"]
Step2 -->|"Value present"| SomeFinal["Maybe.Some(B)"]
Step2 -->|"Filtered or absent"| NoneFinal["Maybe.None"]
None -->|"Short-circuit (bypassed)"| NoneFinal
SomeFinal --> Boundary["Maybe.getOrElse / Maybe.match"]
NoneFinal --> Boundary
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 wrap our raw, potentially missing values into a Maybe
context. This is the boundary where external data enters our typed pipeline.
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:
When invoking operations that throw exceptions where you only need to determine presence versus
failure, use Maybe.tryCatch:
Boundary inspection with type guards: is.some and is.none
Section titled “Boundary inspection with type guards: is.some and is.none”Inside a pipeline, you will rarely inspect a Maybe with if statements — combinators like map,
chain, and match handle branching automatically. However, when interfacing with legacy
imperative APIs or conditional UI branches where you must branch imperatively, use the
Maybe.is.some and Maybe.is.none type guards:
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.
Transforming values with map
Section titled “Transforming values 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 record transformations. Imagine extracting and formatting a user’s
display handle when their profile is optional, but handle is guaranteed on a present profile:
If profile is missing, both map steps are skipped and None is returned. If profile is
present, each transformation runs in sequence.
Chaining optional steps with chain
Section titled “Chaining optional steps with chain”Sometimes, a transformation itself might produce an optional value or fail. For example, navigating
through nested optional properties (avatarUrl?: string) or parsing a string into an integer can
each yield None.
If we were to use map with a function that returns a Maybe, we would end up with a nested type:
Maybe<Maybe<number>>.
To resolve this, we use chain. It applies the transformation and flattens the nested structure,
leaving us with a single Maybe:
This is also how we safely drill through multiple optional properties in a record:
Think of map as a tool for transformations that always return a value once the input is present,
and chain as a tool for transformations that can themselves return None.
Combining independent values with apply
Section titled “Combining independent values with apply”When you have a curried function and multiple independent Maybe values to feed into it,
Maybe.apply applies each optional argument step-by-step. If all inputs are Some, the function is
evaluated; if any input is None, the result is None:
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.
When given a custom TypeScript type guard ((a: A) => a is B), filter automatically refines the
inner type:
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. Like filter, it supports
TypeScript type guards to narrow unvalidated inputs:
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 container and return a concrete value.
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 fallback callback 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
Section titled “Case analysis with match”When you need to perform different logic for both the presence and absence cases, use match:
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.
tapNone executes a side effect when the value is None, leaving the original None unchanged:
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 and Validation
Section titled “Converting to and from Result and Validation”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 supply a callback that returns
an error value if the Maybe is a None:
We can also convert a Maybe directly to a Validation using to.Validation, providing an error
generator for None:
Conversely, if we have a Result and want to discard the error context, we can downgrade it to a
Maybe using from.Result:
Similarly, we can convert a Validation to a Maybe, discarding failure errors:
Swapping container contexts: sequence.Result
Section titled “Swapping container contexts: sequence.Result”When working with optional operations that return fallible results (such as Maybe<Result<E, A>>),
Maybe.sequence.Result swaps the outer and inner contexts, producing Result<E, Maybe<A>>:
Accumulating values: assign / assignTo
Section titled “Accumulating values: assign / assignTo”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 assignTo and assign to cleanly accumulate values key-by-key in a flat,
readable pipeline.
assignTo wraps a value into the pipeline’s accumulator object:
assign 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 assign is ideal for sequential steps where a later 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:
Pipeline composition
Section titled “Pipeline composition”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.
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 (
Arr.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 pipeline step without wrapper functions. - 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.