Skip to content

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.

const rawValue = config.get("timeout");
let timeout = 1000;

if (rawValue !== undefined && rawValue !== null) {
  const parsed = parseInt(rawValue, 10);
  if (!isNaN(parsed) && parsed > 0) {
    timeout = parsed;
  }
}

In standard TypeScript, each transformation step requires guarding against missing intermediate values. With Maybe, the entire check collapses into a single, linear pipeline:

const timeout = pipe(
  Maybe.from.nullable(config.get("timeout")),
  Maybe.chain(parseInteger),
  Maybe.filter((n) => n > 0),
  Maybe.getOrElse(() => 1000),
);

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.

import { Maybe, pipe } from "@pplnd/core";

export type Maybe<A> = Maybe.Some<A> | Maybe.None;

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.

// Wrapping an existing value
const five = Maybe.make.some(5); // Maybe<number>

// Representing explicit absence
const empty = Maybe.make.none(); // Maybe.None

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:

interface AppConfig {
  theme?: "light" | "dark";
}

const config: AppConfig = {};

const theme = Maybe.from.nullable(config.theme); // None

When invoking operations that throw exceptions where you only need to determine presence versus failure, use Maybe.tryCatch:

const parseConfigJson = (raw: string): Maybe<Record<string, unknown>> =>
  Maybe.tryCatch(() => JSON.parse(raw));

parseConfigJson('{"port": 8080}'); // Some({ port: 8080 })
parseConfigJson("invalid json"); // None

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:

const cachedToken = Maybe.from.nullable(localStorage.getItem("auth_token"));

if (Maybe.is.some(cachedToken)) {
  // TypeScript narrows cachedToken to Maybe.Some<string>
  attachAuthorizationHeader(cachedToken.value);
}

if (Maybe.is.none(cachedToken)) {
  redirectToLogin();
}

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.

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.

const double = (n: number) => n * 2;

pipe(Maybe.make.some(5), Maybe.map(double)); // Some(10)
pipe(Maybe.make.none(), Maybe.map(double)); // None

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:

interface User {
  profile?: {
    handle: string;
  };
}

const getFormattedHandle = (user: User): Maybe<string> =>
  pipe(
    Maybe.from.nullable(user.profile),
    Maybe.map((p) => p.handle.trim()),
    Maybe.map((handle) => `@${handle.toLowerCase()}`),
  );

If profile is missing, both map steps are skipped and None is returned. If profile is present, each transformation runs in sequence.

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

const parseInteger = (s: string): Maybe<number> => {
  const n = parseInt(s, 10);
  return isNaN(n) ? Maybe.make.none() : Maybe.make.some(n);
};

// Using map results in nesting:
const nested = pipe(Maybe.make.some("42"), Maybe.map(parseInteger)); // Some(Some(42))

To resolve this, we use chain. It applies the transformation and flattens the nested structure, leaving us with a single Maybe:

const flat = pipe(Maybe.make.some("42"), Maybe.chain(parseInteger)); // Some(42)
const failed = pipe(Maybe.make.some("abc"), Maybe.chain(parseInteger)); // None

This is also how we safely drill through multiple optional properties in a record:

interface UserProfile {
  profile?: {
    avatarUrl?: string;
  };
}

const getAvatarFilename = (user: UserProfile): Maybe<string> =>
  pipe(
    Maybe.from.nullable(user.profile),
    Maybe.chain((p) => Maybe.from.nullable(p.avatarUrl)),
    Maybe.chain((url) => Maybe.from.nullable(url.split("/").pop())),
  );

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.

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:

const buildConnectionUrl = (host: string) => (port: number) => (dbName: string) =>
  `postgres://${host}:${port}/${dbName}`;

const connectionUrl = pipe(
  Maybe.make.some(buildConnectionUrl),
  Maybe.apply(Maybe.from.nullable(env.DB_HOST)),
  Maybe.apply(pipe(Maybe.from.nullable(env.DB_PORT), Maybe.chain(parseInteger))),
  Maybe.apply(Maybe.from.nullable(env.DB_NAME)),
); // Maybe<string>

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.

const isEven = (n: number) => n % 2 === 0;

pipe(Maybe.make.some(4), Maybe.filter(isEven)); // Some(4)
pipe(Maybe.make.some(5), Maybe.filter(isEven)); // None

When given a custom TypeScript type guard ((a: A) => a is B), filter automatically refines the inner type:

type Admin = { role: "admin"; permissions: string[] };
type Account = { role: "member" } | Admin;

const isAdmin = (u: Account): u is Admin => u.role === "admin";

const adminUser = pipe(
  currentAccount, // Maybe<Account>
  Maybe.filter(isAdmin), // Maybe<Admin>
);

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:

const validateAge = (age: number): Maybe<number> =>
  pipe(
    age,
    Maybe.from.Predicate((n) => n >= 18),
  );

const parseString = (raw: unknown): Maybe<string> =>
  pipe(
    raw,
    Maybe.from.Predicate((value): value is string => typeof value === "string"),
  );

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.

The most common exit point is getOrElse. It extracts the value from a Some, or returns a fallback value if we have a None.

pipe(
  Maybe.make.some("admin"),
  Maybe.getOrElse(() => "guest"),
); // "admin"

pipe(
  Maybe.make.none(),
  Maybe.getOrElse(() => "guest"),
); // "guest"

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.

When you need to perform different logic for both the presence and absence cases, use match:

const welcomeMessage = pipe(
  maybeUser,
  Maybe.match({
    some: (user) => `Welcome back, ${user.name}`,
    none: () => "Please log in to continue",
  }),
);

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:

const nullable = Maybe.to.nullable(maybeValue); // T | null
const undefinedVal = Maybe.to.undefined(maybeValue); // T | undefined

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.

pipe(
  Maybe.make.some("Configuration parsed successfully"),
  Maybe.tap((msg) => console.log(msg)),
  Maybe.map((msg) => msg.toUpperCase()),
); // Logs message, then continues to produce Some("CONFIGURATION PARSED SUCCESSFULLY")

tapNone executes a side effect when the value is None, leaving the original None unchanged:

pipe(
  findCachedSession(sessionId),
  Maybe.tapNone(() => metrics.increment("cache.miss")),
  Maybe.recover(() => fetchDatabaseSession(sessionId)),
);

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.

const getTheme = (userId: string): string =>
  pipe(
    Maybe.from.nullable(cache.get(`theme:${userId}`)),
    Maybe.recover(() => Maybe.from.nullable(db.get(`theme:${userId}`))),
    Maybe.getOrElse(() => "light"),
  );

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:

import { Result } from "@pplnd/core";

const parseEmail = (email: string): Maybe<string> =>
  pipe(
    email,
    Maybe.from.Predicate((e) => e.includes("@")),
  );

const signup = (rawEmail: string) =>
  pipe(
    parseEmail(rawEmail),
    Maybe.to.Result(() => "Email address must contain an '@' symbol"),
  ); // Result<string, string>

We can also convert a Maybe directly to a Validation using to.Validation, providing an error generator for None:

import { Validation } from "@pplnd/core";

const validatedEmail = pipe(
  parseEmail("invalid"),
  Maybe.to.Validation(() => "Invalid email"),
); // Failed(["Invalid email"])

Conversely, if we have a Result and want to discard the error context, we can downgrade it to a Maybe using from.Result:

const maybeValue = Maybe.from.Result(Result.make.err("An error occurred")); // None

Similarly, we can convert a Validation to a Maybe, discarding failure errors:

const maybeValid = Maybe.from.Validation(Validation.make.failed("An error occurred")); // None
const maybeSuccess = Maybe.from.Validation(Validation.make.passed(42)); // Some(42)

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>>:

Maybe.sequence.Result(Maybe.make.some(Result.make.ok(42))); // Ok(Some(42))
Maybe.sequence.Result(Maybe.make.some(Result.make.err("e"))); // Err("e")
Maybe.sequence.Result(Maybe.make.none()); // Ok(None)

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:

const userProfile = pipe(
  getUser(userId),
  Maybe.chain((user) =>
    pipe(
      getPreferences(user.id),
      Maybe.map((prefs) => ({ user, prefs })),
    )
  ),
  Maybe.chain(({ user, prefs }) =>
    pipe(
      getThemeById(prefs.themeId),
      Maybe.map((theme) => ({ user, prefs, theme })),
    )
  ),
);

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:

pipe(
  Maybe.make.some(42),
  Maybe.assignTo("value"),
); // Some({ value: 42 })

assign runs a new operation using the accumulated object and attaches the result to a new key:

const userProfile = pipe(
  getUser(userId),
  Maybe.assignTo("user"),
  Maybe.assign("prefs", ({ user }) => getPreferences(user.id)),
  Maybe.assign("theme", ({ prefs }) => getThemeById(prefs.themeId)),
); // Some({ user: User, prefs: Preferences, theme: Theme })

If any step fails (yielding None), the entire pipeline short-circuits and propagates None immediately.


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:

const parseRole = Maybe.from.Predicate((r: string) => r === "admin" || r === "user");

const user = Maybe.struct({
  name: Maybe.make.some("Alice"),
  age: Maybe.make.some(30),
  role: parseRole("user"),
}); // Some({ name: "Alice", age: 30, role: "user" })

If multiple fields are None, Maybe.struct short-circuits immediately on the first None encountered in key order:

const reportQuery = Maybe.struct({
  startDate: Maybe.make.some("2026-01-01"),
  endDate: Maybe.make.none(),
  regionId: Maybe.make.none(),
}); // None

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.

const getPageOffset = (rawOffset: string | undefined, pageSize: number): number =>
  pipe(
    Maybe.from.nullable(rawOffset), // Maybe<string>
    Maybe.chain(parseInteger), // Maybe<number>
    Maybe.filter((offset) => offset >= 0), // Maybe<number>
    Maybe.map((offset) => offset * pageSize), // Maybe<number>
    Maybe.getOrElse(() => 0), // number
  );

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.


  • 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 ambiguous undefined that easily leaks into downstream computations. Maybe turns 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 Maybe allows 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 as startDate, endDate, and regionId) to all be present before executing a query. Maybe.struct combines an object of individual Maybe fields into a single Maybe<Record>, resolving to Some only if every required field is present and short-circuiting on the first None.
  • 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 yield None. Arr.compact and Maybe combinators 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 null or undefined permits callers to inadvertently ignore missing values until a runtime error triggers downstream. Returning Maybe<A> makes absence an explicit type guarantee, ensuring callers handle both presence and absence at compile time.