Skip to content

Designing with states

TypeScript is an exceptional language for describing the shape of data, but it leaves a notable gap when it comes to describing the states our applications are actually in.

We are provided with excellent low-level primitives: null and undefined for absence, try/catch blocks for errors, and Promise for asynchronous operations. These are mechanisms, not models. Because the language does not provide first-class structures to carry the semantic meaning of these states, every project is forced to invent its own ad-hoc conventions.

pipelined gives these common programming situations names. By representing these states as data structures rather than keywords or language constructs, we make them explicit, composable, and visible in our types.


We spend a significant amount of our day querying databases, searching maps, and checking configurations for values that might not be there. In standard TypeScript, we represent this with T | null or T | undefined.

This is easy to write, but it forces the caller of a function to immediately stop and write conditional logic. If they forget, strict mode may warn them, but the checking logic itself remains intertwined with the happy path of the program.

Maybe<A> is a data structure representing this choice: it is either Some<A> (a value is present) or None (it is not). Transformations on a Maybe automatically bypass the absent case, allowing us to describe our logic linearly:

import { pipe } from "@nlozgachev/pipelined/composition";
import { Maybe } from "@nlozgachev/pipelined/core";

const city = pipe(
  users.get(userId),                 // User | undefined
  Maybe.fromNullable,                // Maybe<User>
  Maybe.map((u) => u.address),       // Maybe<Address>
  Maybe.map((a) => a.city),          // Maybe<string>
  Maybe.getOrElse(() => "Unknown"),  // string
);

If any step produces a None, the subsequent steps are skipped and getOrElse provides the fallback. The presence check is decoupled from the business logic.


Exceptions in JavaScript are wild. They can be thrown by any statement, and their type is always unknown. Nothing in a function’s signature indicates to the caller that it might throw, leading to unhandled runtime failures or defensive try/catch blocks cluttering our code.

Result<E, A> models a synchronous operation that can fail. It is either Ok<A> (representing success) or Err<E> (a typed failure). By returning a Result, we bring the error into the type signature itself, forcing the compiler to verify that the failure is handled.

import { pipe } from "@nlozgachev/pipelined/composition";
import { Result } from "@nlozgachev/pipelined/core";

const parseId = (raw: string): Result<string, number> => {
  const n = Number(raw);
  return isNaN(n) ? Result.err("Input is not a valid number") : Result.ok(n);
};

const route = pipe(
  parseId(input),
  Result.map((id) => `/users/${id}`),
  Result.getOrElse(() => "/users/unknown"),
);

When we validate a complex data structure—like a registration form—we often want to present the user with a list of all errors.

If we use a standard Result or try/catch pattern, the program short-circuits at the very first failure. This is correct for sequential business logic, but frustrating for user interfaces where displaying only one error at a time forces a slow, trial-and-error cycle of form submission.

Validation<E, A> is a data structure designed specifically to accumulate errors. It is either Passed<A> or Failed<E>. When we combine multiple validation values, their failures accumulate instead of short-circuiting:

import { Validation } from "@nlozgachev/pipelined/core";

const validateName = (s: string): Validation<string, string> =>
  s.trim() ? Validation.passed(s.trim()) : Validation.failed("Name is required");

const validateAge = (n: number): Validation<string, number> =>
  n >= 0 ? Validation.passed(n) : Validation.failed("Age must be non-negative");

If both fields are invalid, both validation errors are gathered and returned together, enabling clean, comprehensive user feedback.


An asynchronous operation in standard JavaScript is represented by a Promise. Promises are eager—they begin executing the moment they are created—and they can reject with any value.

TaskResult<E, A> is a lazy, infallible blueprint for an asynchronous operation that eventually yields a Result<E, A>. Because it is lazy, nothing runs until we explicitly invoke it. Because it is infallible at the promise level, the outer Task always resolves successfully, carrying our typed Result inside.

import { pipe } from "@nlozgachev/pipelined/composition";
import { TaskResult } from "@nlozgachev/pipelined/core";

const fetchUser = (id: string): TaskResult<string, User> =>
  TaskResult.tryCatch(
    (signal) => fetch(`/users/${id}`, { signal }).then((r) => r.json()),
    (e) => `Network failure: ${e}`,
  );

const greeting = pipe(
  fetchUser("42"),
  TaskResult.map((user) => `Hello, ${user.name}`),
  TaskResult.getOrElse(() => "Welcome, guest"),
);

// Execution is deferred. Nothing has run yet.
const result = await greeting(); // "Hello, Alice" (never throws)

Consider how we typically model a data-fetching operation in UI state:

interface State {
  isLoading: boolean;
  data: User | null;
  error: Error | null;
}

This model is fragile. It permits invalid combinations: isLoading could be true while data is present, or both error and data could be null simultaneously. Furthermore, the state of “we haven’t requested the data yet” is indistinguishable from “we requested it and found nothing.”

RemoteData<E, A> represents these states explicitly. It is a union of exactly four constructors: NotAsked, Loading, Failure<E>, and Success<A>. The compiler enforces that every state is accounted for:

import { RemoteData } from "@nlozgachev/pipelined/core";

const renderUI = RemoteData.match({
  notAsked: () => renderPlaceholder(),
  loading:  () => renderSpinner(),
  failure:  (err) => renderError(err),
  success:  (user) => renderProfile(user),
});

These structures are not abstract mathematical exercises. They are simple, practical designs that bring clarity to standard programming patterns.

To understand how to sequence these transformations linearly, read Thinking in pipelines, which covers the mechanics of pipe, flow, and the data-last design that enables them. If you already have a specific problem to solve, you can head directly to the in-depth guides for each module.