7 min read

Type‑Safe Functional Programming in TypeScript: Harnessing Algebraic Data Types & Pattern Matching

Learn how to model domain logic with algebraic data types and pattern‑match them safely in TypeScript, turning runtime errors into compile‑time guarantees.
Type‑Safe Functional Programming in TypeScript: Harnessing Algebraic Data Types & Pattern Matching

Introduction

Functional programming (FP) isn’t a buzzword reserved for Haskell or OCaml; it’s a set of principles that can make JavaScript/TypeScript code more predictable, composable, and type‑safe. Two pillars of FP—Algebraic Data Types (ADTs) and pattern matching—have become practical in modern TypeScript thanks to discriminated unions and clever utility libraries.

In this article we’ll:

  1. Explain what ADTs are and how they map to TypeScript’s type system.
  2. Show how to build reusable, type‑safe ADTs such as Result and Option.
  3. Demonstrate pattern‑matching techniques (native switch + type guards, and the lightweight ts-pattern library).
  4. Walk through a real‑world example: a small HTTP‑client wrapper that validates responses without throwing at runtime.

By the end you’ll have a toolbox that lets the compiler catch impossible branches, turning many runtime bugs into compile‑time errors.


1. Algebraic Data Types in TypeScript

1.1 Sum Types (Tagged Unions)

A sum type (also called a variant or discriminated union) represents a value that can be one of several alternatives, each tagged with a literal discriminator. In TypeScript this is expressed with a union of object types that share a common type (or kind) property.

type Shape =
  | { type: "circle"; radius: number }
  | { type: "rectangle"; width: number; height: number }
  | { type: "square"; size: number };

The compiler now knows that if shape.type === "circle" then shape must have a radius. This is the essence of exhaustiveness checking: every possible case can be accounted for.

1.2 Product Types (Interfaces & Tuples)

A product type combines several values together, like an object or a tuple. TypeScript’s interface and tuple syntax already give us product types.

type Point = [x: number, y: number]; // tuple product type
interface User {
  id: string;
  name: string;
  email?: string; // optional fields are still part of the product
}

1.3 Why “Algebraic”?

The term “algebraic” comes from the fact that you can compose sum and product types to model complex domains. For example, a Result type (a classic FP construct) is a sum of two products:

type Result<T, E> = 
  | { ok: true; value: T }   // product: { ok, value }
  | { ok: false; error: E }; // product: { ok, error }

The compiler can now enforce that a Result<number, string> is either a successful number or an error string—never both.


2. Building Reusable ADTs

2.1 The Result Monad

export type Ok<T> = { ok: true; value: T };
export type Err<E> = { ok: false; error: E };
export type Result<T, E> = Ok<T> | Err<E>;

export const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
export const err = <E>(error: E): Result<never, E> => ({ ok: false, error });

Notice the use of never to indicate that a successful Result never carries an error, and vice‑versa. This makes downstream code type‑safe without any any casts.

2.2 The Option (Maybe) Type

export type Some<T> = { kind: "some"; value: T };
export type None = { kind: "none" };
export type Option<T> = Some<T> | None;

export const some = <T>(value: T): Option<T> => ({ kind: "some", value });
export const none: None = { kind: "none" };

Option is useful when a value may be absent but you want to avoid null/undefined pitfalls.

2.3 Helper Functions (Map, FlatMap)

export const map = <T, U>(r: Result<T, any>, fn: (v: T) => U): Result<U, any> =>
  r.ok ? ok(fn(r.value)) : r;

export const flatMap = <T, U, E>(r: Result<T, E>, fn: (v: T) => Result<U, E>): Result<U, E> =>
  r.ok ? fn(r.value) : r;

These combinators let you chain operations while preserving the type‑safety guarantees of the underlying ADT.


3. Pattern Matching in TypeScript

3.1 Native switch + Type Guards

The simplest way to pattern‑match a discriminated union is a switch statement:

function area(shape: Shape): number {
  switch (shape.type) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "square":
      return shape.size ** 2;
    // No default → compiler warns if a case is missing
  }
}

If a new variant is added to Shape, TypeScript will emit an error that the switch is no longer exhaustive.

3.2 Exhaustiveness Helper

Sometimes you want a compile‑time guarantee that all branches are covered, even when using if/else. A tiny helper can enforce this:

function assertNever(x: never): never {
  throw new Error(`Unexpected value: ${x}`);
}
function describeResult<T, E>(r: Result<T, E>): string {
  if (r.ok) return `Success: ${r.value}`;
  if (!r.ok) return `Failure: ${r.error}`;
  return assertNever(r); // If a new shape appears, compiler errors here
}

3.3 Using ts-pattern for Concise Matching

The community library ts-pattern provides a functional match API that feels like pattern matching in Rust or Scala, while staying fully type‑safe.

import { match } from "ts-pattern";

function render(shape: Shape): string {
  return match(shape)
    .with({ type: "circle", radius: Number }, s => `🟠 radius=${s.radius}`)
    .with({ type: "rectangle", width: Number, height: Number }, s => `▭ ${s.width}×${s.height}`)
    .with({ type: "square", size: Number }, s => `◼ size=${s.size}`)
    .exhaustive(); // compile‑time check
}

exhaustive() forces the compiler to verify that every possible Shape variant is handled. If you later add a triangle variant, the code will no longer compile until you add a matching clause.


4. Real‑World Example: A Type‑Safe HTTP Client

Imagine a small service that calls a third‑party JSON API. The API returns either a successful payload or an error object. Instead of checking response.ok and then casting await response.json() to a type, we can model the contract with ADTs.

4.1 Defining the API Contract

// The shape of a successful response
interface UserDto {
  id: string;
  name: string;
  email: string;
}

// The shape of an error response
interface ApiError {
  code: number;
  message: string;
}

// Result type for the endpoint
type UserResult = Result<UserDto, ApiError>;

4.2 The Client Wrapper

async function fetchUser(id: string): Promise<UserResult> {
  const resp = await fetch(`https://api.example.com/users/${id}`);

  // Parse JSON once – we’ll decide the variant afterwards
  const payload = await resp.json();

  if (resp.ok) {
    // TypeScript cannot infer that payload matches UserDto,
    // so we perform a *runtime* guard (still type‑safe)
    if (isUserDto(payload)) {
      return ok(payload);
    }
    // If the shape is wrong, treat it as an error
    return err({ code: 500, message: "Invalid payload" });
  }

  // Non‑2xx responses are assumed to follow ApiError shape
  if (isApiError(payload)) {
    return err(payload);
  }

  return err({ code: resp.status, message: "Unknown error format" });
}

/* Runtime type guards – keep them tiny and focused */
function isUserDto(x: any): x is UserDto {
  return typeof x.id === "string" && typeof x.name === "string" && typeof x.email === "string";
}
function isApiError(x: any): x is ApiError {
  return typeof x.code === "number" && typeof x.message === "string";
}

The function returns a Result<UserDto, ApiError> that callers must handle explicitly.

4.3 Consuming the Result with Pattern Matching

async function showUser(id: string) {
  const result = await fetchUser(id);

  // Using ts-pattern for a clean match
  return match(result)
    .with({ ok: true }, ({ value }) => `👤 ${value.name} <${value.email}>`)
    .with({ ok: false }, ({ error }) => `❗ API error ${error.code}: ${error.message}`)
    .exhaustive();
}

If a future change adds a third variant to Result (e.g., a pending state), the compiler will force us to update the match expression.

4.4 Benefits

Benefit Explanation
No unchecked any All JSON parsing is guarded by runtime type checks that produce typed ADTs.
Explicit error handling Callers cannot ignore errors; they must pattern‑match on ok.
Exhaustiveness guarantees Adding a new error shape forces a compile‑time update wherever Result is matched.
Composable map, flatMap, and other combinators let you chain transformations without losing type safety.

5. Composing ADTs for Complex Domains

ADTs shine when you combine them. Consider a simple state machine for a file upload:

type UploadState =
  | { status: "idle" }
  | { status: "uploading"; progress: number } // 0‑100
  | { status: "success"; url: string }
  | { status: "error"; reason: string };

A reducer can be written with pattern matching:

function uploadReducer(state: UploadState, action: UploadAction): UploadState {
  return match(action)
    .with({ type: "start" }, () => ({ status: "uploading", progress: 0 }))
    .with({ type: "progress", pct: Number }, ({ pct }) => ({
      status: "uploading",
      progress: pct,
    }))
    .with({ type: "done", url: String }, ({ url }) => ({ status: "success", url }))
    .with({ type: "fail", reason: String }, ({ reason }) => ({ status: "error", reason }))
    .exhaustive();
}

Because each branch returns a concrete UploadState variant, the UI layer can safely switch on state.status without fearing a missing case.


6. Testing ADTs – Property‑Based Approach

When you have pure functions that operate on ADTs, testing becomes straightforward:

import { expect } from "chai";

describe("Result.map", () => {
  it("preserves error values", () => {
    const failure = err("boom");
    const mapped = map(failure, (n) => n * 2);
    expect(mapped).to.deep.equal(failure);
  });

  it("applies fn to Ok values", () => {
    const success = ok(3);
    const mapped = map(success, (n) => n + 1);
    expect(mapped).to.deep.equal(ok(4));
  });
});

Since the types guarantee that map can only receive a Result, you don’t need to test for “invalid inputs” – the compiler already blocks them.


7. Common Pitfalls & How to Avoid Them

Pitfall Fix
Using any in guards Keep runtime guards minimal and return a type predicate (x is Foo).
Forgetting exhaustive() Always end a match chain with .exhaustive() (or a manual assertNever).
Mixing null/undefined with Option Convert external nullable values early: const opt = value == null ? none : some(value);
Over‑engineering Start with simple unions; only introduce generic monads (Result<T,E>) when you need composability.
Duplicating shape definitions Keep a single source of truth for API contracts (e.g., a shared *.d.ts file) and reuse it in both runtime guards and type definitions.

8. Conclusion

Algebraic data types and pattern matching give TypeScript developers a type‑first way to model uncertainty—whether it’s a network error, an optional configuration value, or a complex domain state. By embracing discriminated unions, building reusable ADTs like Result and Option, and leveraging pattern‑matching utilities (native switch or ts-pattern), you can:

  • Eliminate large classes of runtime exceptions.
  • Make error handling an explicit part of the API surface.
  • Gain compile‑time exhaustiveness checks that scale with your codebase.

The functional style may feel different at first, but the payoff is code that fails early, reads clearly, and composes effortlessly—exactly what modern TypeScript projects need to stay robust as they grow.

Happy typing!