Skip to content

Designing with states

Standard TypeScript models runtime conditions with untyped exceptions (throw), missing pointers (null/undefined), and raw promises (Promise). In multi-step pipelines, these primitives force defensive branching and obscure edge cases in type signatures.

@nlozgachev/pipelined/core represents these runtime states as explicit discriminated unions.


Accessing missing properties, dictionary keys, or query results yields undefined or null. Handling these with manual if guards interrupts linear pipeline composition.

Maybe<A> represents optionality as a discriminated union: Some<A> (value present) or None (empty). Subsequent mapping steps run only when a value exists:

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

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

Functions can throw exceptions of type unknown at any statement without indicating the failure in their return type. Callers must either wrap calls in try/catch or risk unhandled runtime crashes.

Result<E, A> brings failure into the return type: Ok<A> for success or Err<E> for typed errors.

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.make.err("Input is not a valid number") : Result.make.ok(n);
};

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

Accumulating Multiple Errors: Validation<E, A>

Section titled “Accumulating Multiple Errors: Validation<E, A>”

Result short-circuits on the first failure. While correct for sequential steps, this is counterproductive for form validation and schema parsing where users need a list of all invalid fields at once.

Validation<E, A> evaluates independent checks and accumulates all failures into a non-empty array (NonEmptyArr<E>):

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

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

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

Lazy Infallible Blueprints: Task.Result<E, A>

Section titled “Lazy Infallible Blueprints: Task.Result<E, A>”

Standard Promise<T> begins execution immediately upon instantiation and rejects with untyped errors.

Task.Result<E, A> is a zero-argument function that executes only when called and resolves to a Result<E, A>, guaranteeing the outer promise never rejects:

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

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

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

// Execution occurs on invocation; always resolves
const result = await greeting(); // "Hello, Alice"

Explicit UI Lifecycle States: RemoteData<E, A>

Section titled “Explicit UI Lifecycle States: RemoteData<E, A>”

Modeling async request state with multiple boolean flags (isLoading, error, data) permits invalid combinations, such as displaying a stale error message while a new request is loading.

RemoteData<E, A> defines the four mutually exclusive states of an asynchronous operation:

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

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

3. Concurrency Strategies & Event Pipelines

Section titled “3. Concurrency Strategies & Event Pipelines”

Declarative Async Concurrency: Op<I, E, A>

Section titled “Declarative Async Concurrency: Op<I, E, A>”

Managing in-flight request cancellation, debouncing, and retries in UI components typically requires manual AbortController handles and state flags.

Op<I, E, A> separates the async task definition from its execution policy:

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

// 1. Define the task once
const searchApi = Op.create(
  (signal) => (query: string) =>
    fetch(`/api/search?q=${query}`, { signal }).then((r) => r.json()),
  (err) => `Search failed: ${err}`,
);

// 2. Configure execution strategy (cancels previous request on new input)
const searchRunner = Op.interpret(searchApi, {
  strategy: "restartable",
  retry: { attempts: 2 },
});

Standard event emitters leave message payloads untyped, while complex reactive stream frameworks introduce heavy operators for basic event coordination.

Stream<S> provides a type-safe event pipeline for matching event sequences and accumulating state over time:

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

type AppEvents = {
  login: { userId: string };
  logout: void;
};

const authStream = Stream.create<AppEvents>();

// Subscribe to typed events
authStream.on("login", ({ userId }) => {
  console.log(`User logged in: ${userId}`);
});

Use this matrix to pick the right data structure for your scenario:

Situation Recommended Type Success State Failure / Empty State Execution / Nature
Optional value, cache miss, or nullable property Maybe<A> Some<A> None Synchronous, short-circuits
Fallible synchronous business logic Result<E, A> Ok<A> Err<E> Synchronous, short-circuits
Multi-field validation (forms, configs) Validation<E, A> Passed<A> Failed<NonEmptyArr<E>> Synchronous, accumulates
Lazy async operation with typed error channel Task.Result<E, A> Ok<A> Err<E> Asynchronous, deferred
UI data request lifecycle tracking RemoteData<E, A> Success<A> Failure<E>, Loading, NotAsked State union for UI render
Async task needing cancellation, concurrency, or retries Op<I, E, A> Ok<A> Err<E> Interpreted execution engine
Typed pub/sub event pipeline & state accumulation Stream<S> Message<S> N/A Reactive event bus

Every data container in pipelined shares a consistent, predictable set of combinator names. Once you learn how an operation behaves on Maybe, you already know how it works across Result, Validation, Task.Result, and RemoteData:

Operation Purpose Example
map Transforms the inner success value while preserving the container structure. Result.map((n) => n * 2)
mapError Transforms the error payload without modifying success values. Result.mapError((e) => e.message)
chain Sequences a function returning another container, flattening nested layers. Maybe.chain((user) => lookup(user.id))
getOrElse Unwraps the value, falling back to a default thunk if absent or failed. Maybe.getOrElse(() => DEFAULT_USER)
match Exhaustively unwraps all variants using a named-case object. RemoteData.match({ loading, success, failure, notAsked })
fold Exhaustively unwraps all variants using positional callbacks. Result.fold(onErr, onOk)
tap Intercepts the success value for side effects without modifying the data. Task.Result.tap((u) => log(u.id))
recover Catches a failure variant and substitutes a fallback container. Result.recover((err) => Result.make.ok(cached))

To keep root module objects clean and focused on pipeline combinators, instantiation, conversions, and type guards are organized into four standard drawers across every module:

Drawer Purpose Examples
make.* Instantiates a specific variant of a discriminated union. Result.make.ok(42)
Maybe.make.some(5)
RemoteData.make.loading()
from.* Converts external types or primitives into the container. Maybe.from.nullable(val)
Result.from.Validation(fn)
Tuple.from.pair(a, b)
to.* Converts the container back to language primitives or other containers. Maybe.to.nullable(maybe)
Result.to.Maybe(result)
Str.to.number(str)
is.* Type guard predicates for conditional branching and filtering. Result.is.ok(result)
Arr.is.empty(array)
Num.is.even(n)

In a real-world feature, these modules compose across boundaries: validating user input, executing a cancellable network call, and updating UI state:

import { pipe } from "@nlozgachev/pipelined/composition";
import { Op, RemoteData, Validation } from "@nlozgachev/pipelined/core";

// 1. Validate form fields independently (Validation)
const validateSignup = (name: string, email: string) =>
  Validation.struct({
    name: name.trim() ? Validation.make.passed(name) : Validation.make.failed("Name required"),
    email: email.includes("@") ? Validation.make.passed(email) : Validation.make.failed("Invalid email"),
  });

// 2. Define an async submission operation with cancellation (Op)
const submitUserOp = Op.interpret(
  Op.create(
    (signal) => (payload: { name: string; email: string }) =>
      fetch("/api/register", {
        method: "POST",
        body: JSON.stringify(payload),
        signal,
      }).then((r) => r.json()),
    (err) => `Registration error: ${err}`,
  ),
  { strategy: "exclusive" }, // Prevents double-clicks while in flight
);

// 3. UI State transitions (RemoteData)
let uiState: RemoteData<string, { id: string }> = RemoteData.make.notAsked();

const handleRegister = async (name: string, email: string) => {
  const validation = validateSignup(name, email);

  if (Validation.is.failed(validation)) {
    uiState = RemoteData.make.failure(validation.errors.join(", "));
    return;
  }

  uiState = RemoteData.make.loading();
  const outcome = await submitUserOp(validation.value);

  uiState = pipe(
    outcome,
    RemoteData.from.Result,
  );
};