Beyond switch-case: Type-safe Pattern Matching in TypeScript

Beyond switch-case: Type-safe Pattern Matching in TypeScript

April 28, 2025

13 min read

Beyond switch-case: Type-safe Pattern Matching in TypeScript

Pattern matching in TypeScript has become an essential part of my daily workflow, both in React and Node.js. Personally, I haven't found a need for switch-case statements—I can't even recall the last time I used one. In this post, I'll share practical pattern matching techniques that I rely on regularly. All the reusable code can be found in the GitHub repository.

The Resolver Pattern: Type-safe Function Selection

Often, when we reach for a switch-case statement, our real goal is to run different logic depending on the value of a union type or enum. For example, in a music theory app, we need to generate different note patterns for different scale types. Each scale type—like blues, full, or pentatonic—has its own function that implements the specific logic for that scale, but all of these functions share the same input and output types, defined by the ScalePatternResolver interface. By creating a record that maps each scale type to its corresponding resolver function, we can easily select and call the right function based on the scale type, without needing a switch or if-else chain.

import { Scale } from "../Scale"
import { ScaleType } from "../ScaleType"

import { getBluesScalePattern } from "./blues"
import { getFullScalePattern } from "./full"
import { getPentatonicPattern } from "./pentatonic"
import { ScalePatternResolver } from "./ScalePatternResolver"

const resolvers: Record<ScaleType, ScalePatternResolver> = {
  blues: getBluesScalePattern,
  full: getFullScalePattern,
  pentatonic: getPentatonicPattern,
}

type RootScalePatternResolverInput = {
  index: number
  scale: Scale
}

export const getScalePattern = (input: RootScalePatternResolverInput) => {
  const resolver = resolvers[input.scale.type]

  return resolver(input)
}

If we ever introduce a new scale type but forget to add its resolver to this record, TypeScript will immediately flag the omission as an error. This safety net means we can confidently extend our code in the future, knowing that TypeScript will help us catch any missing cases during development.

The Match Function: Inline Pattern Matching

Most of the time, the logic we need to run for each value of a union type or enum is simple enough that creating a separate function for every case would be overkill. In these situations, it's much easier to define small handlers for each value right where we need them. That's where the match function is especially useful. For example, suppose we want to calculate a task's deadline timestamp based on its cadence.

import { match } from "@lib/utils/match"
import { convertDuration } from "@lib/utils/time/convertDuration"
import { TaskCadence } from "@product/entities/TaskFactory"
import { endOfISOWeek } from "date-fns"
import { endOfDay, getDaysInMonth, endOfMonth } from "date-fns"

type Input = {
  cadence: TaskCadence
  deadlineIndex?: number | null
  at: number
}

export const getRecurringTaskDeadline = ({
  cadence,
  deadlineIndex,
  at,
}: Input) =>
  match(cadence, {
    workday: () => {
      const dayEndedAt = endOfDay(at).getTime()
      const lastWorkdayEndedAt =
        endOfISOWeek(at).getTime() - convertDuration(2, "d", "ms")

      return Math.min(dayEndedAt, lastWorkdayEndedAt)
    },
    day: () => endOfDay(at).getTime(),
    week: () =>
      endOfISOWeek(at).getTime() -
      convertDuration(6 - (deadlineIndex ?? 0), "d", "ms"),
    month: () => {
      const daysInMonth = getDaysInMonth(at)
      return (
        endOfMonth(at).getTime() -
        convertDuration(
          daysInMonth - 1 - Math.min(deadlineIndex ?? 0, daysInMonth - 1),
          "d",
          "ms",
        )
      )
    },
  })

Pattern Matching for API Interactions

Here's another example that shows how pattern matching can streamline logic when working with multiple OAuth providers. Instead of relying on nested conditionals, the match function lets us directly associate each provider with its specific logic for fetching user information—Google uses a GET request with an authorization header, while Facebook requires query parameters in the URL. This approach keeps the code organized and easy to extend: if we add a new provider to the OAuthProvider type, TypeScript will prompt us to handle it in the match function, helping prevent overlooked cases and making future updates straightforward.

import { match } from "@lib/utils/match"
import { addQueryParams } from "@lib/utils/query/addQueryParams"
import { OAuthProvider } from "@product/entities/OAuthProvider"

import { queryOAuthProvider } from "./queryOAuthProvider"

interface GetOAuthUserInfoParams {
  accessToken: string
  provider: OAuthProvider
}

interface UserInfoResponse {
  email?: string
  name?: string
}

const GOOGLE_USER_INFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"
const FACEBOOK_USER_INFO_URL = "https://graph.facebook.com/me"

export const getOAuthUserInfo = async ({
  accessToken,
  provider,
}: GetOAuthUserInfoParams) => {
  const actionName = `get ${provider} user info`

  return match(provider, {
    google: async () =>
      queryOAuthProvider<UserInfoResponse>(actionName, GOOGLE_USER_INFO_URL, {
        method: "GET",
        headers: {
          Authorization: `Bearer ${accessToken}`,
        },
      }),
    facebook: async () =>
      queryOAuthProvider<UserInfoResponse>(
        actionName,
        addQueryParams(FACEBOOK_USER_INFO_URL, {
          fields: ["email", "name"].join(","),
          access_token: accessToken,
        }),
      ),
  })
}

The Core Implementation

At the heart of this approach is a simple yet powerful match function. This utility takes a value—such as a string, number, or symbol—and a set of handlers for each possible value. It then calls the handler that matches the input, returning its result. By using this function, we can replace verbose conditional logic with a concise, type-safe pattern that's easy to read and extend.

export function match<T extends string | number | symbol, V>(
  value: T,
  handlers: { [key in T]: () => V },
): V {
  const handler = handlers[value]

  return handler()
}

Bringing Pattern Matching to React

While the match function works well for plain TypeScript logic, it's also possible to bring this pattern matching approach into React components. The Match component provides a way to render different UI fragments based on a value, using a prop-driven pattern that closely mirrors the type-safe logic of the utility function. By mapping each possible value to a render function, Match makes it straightforward to conditionally display content in a declarative and maintainable way.

import { ReactNode } from "react"

import { ValueProp } from "../props"

type MatchProps<T extends string | number | symbol> = Record<
  T,
  () => ReactNode
> &
  ValueProp<T>

export function Match<T extends string | number | symbol>(
  props: MatchProps<T>,
) {
  const render = props[props.value]

  return <>{render()}</>
}

Visual State Indicators with Pattern Matching

For instance, let's say we need to display a different visual indicator depending on the current status of a task: a checkbox when it's completed, a spinner while it's active, and an empty placeholder when it's still pending. This approach makes it easy to see at a glance what stage each task is in, using clear and distinct UI elements for each possible state.

import styled from "styled-components"

import { Match } from "../../base/Match"
import { centerContent } from "../../css/centerContent"
import { sameDimensions } from "../../css/sameDimensions"
import { HStack } from "../../css/stack"
import { CheckIcon } from "../../icons/CheckIcon"
import { Spinner } from "../../loaders/Spinner"
import { ChildrenProp, KindProp } from "../../props"
import { Text, TextColor } from "../../text"
import { getColor } from "../../theme/getters"

export type ProgressListItemKind = "completed" | "active" | "pending"

const IndicatorContainer = styled.div`
  ${sameDimensions(24)}
  ${centerContent}
`

const CompletedIndicator = styled(CheckIcon)`
  color: ${getColor("success")};
`

const ActiveIndicator = styled(Spinner)`
  color: ${getColor("contrast")};
`

const kindToColor: Record<ProgressListItemKind, TextColor> = {
  completed: "regular",
  active: "contrast",
  pending: "shy",
}

export const ProgressListItem = ({
  kind,
  children,
}: KindProp<ProgressListItemKind> & ChildrenProp) => {
  return (
    <HStack gap={12} alignItems="center">
      <IndicatorContainer>
        <Match
          value={kind}
          completed={() => <CompletedIndicator />}
          active={() => <ActiveIndicator />}
          pending={() => <div />}
        />
      </IndicatorContainer>
      <Text color={kindToColor[kind]}>{children}</Text>
    </HStack>
  )
}

Managing Query States with Pattern Matching

We can also create a custom pattern matching component tailored for handling the results of queries or mutations—something I often need when working with react-query. Typically, a query result might be pending, successful, or in an error state, and sometimes there's an inactive state, such as when a query is disabled based on certain conditions. The MatchQuery component lets you define handlers for each of these cases, allowing you to specify only the ones relevant to your situation and keep your UI logic clean and focused.

import { ValueProp } from "@lib/utils/entities/props"
import { ReactNode } from "react"

import { Query } from "../Query"

export type MatchQueryProps<T, E = unknown> = ValueProp<Query<T, E>> & {
  error?: (error: E) => ReactNode
  pending?: () => ReactNode
  success?: (data: T) => ReactNode
  inactive?: () => ReactNode
}

export function MatchQuery<T, E = unknown>({
  value,
  error = () => null,
  pending = () => null,
  success = () => null,
  inactive = () => null,
}: MatchQueryProps<T, E>) {
  if (value.data !== undefined) {
    return <>{success(value.data)}</>
  }

  if (value.error) {
    return <>{error(value.error)}</>
  }

  if (value.isLoading === false) {
    return <>{inactive()}</>
  }

  if (value.isPending) {
    return <>{pending()}</>
  }

  return null
}

MatchQuery in Action

Here's how MatchQuery helps manage different query states in the UI: it renders the scoreboard when the data is available, shows a spinner while the query is loading, and displays an error message if something goes wrong. This approach keeps the component focused and makes each state easy to handle and understand.

import { Panel } from "@lib/ui/css/panel"
import { HStack, VStack } from "@lib/ui/css/stack"
import { Spinner } from "@lib/ui/loaders/Spinner"
import { MatchQuery } from "@lib/ui/query/components/MatchQuery"
import { Text } from "@lib/ui/text"
import { SectionTitle } from "@lib/ui/text/SectionTitle"
import { useApiQuery } from "@product/api-ui/hooks/useApiQuery"
import {
  ScoreboardPeriod,
  scoreboardPeriodInDays,
} from "@product/entities/PerformanceScoreboard"
import { LastScoreboardUpdate } from "@product/ui/scoreboard/LastScoreboardUpdate"
import { ScoreboardTable } from "@product/ui/scoreboard/ScoreboardTable"

export const Scoreboard = () => {
  const scoreboardPeriod: ScoreboardPeriod = "week"
  const query = useApiQuery("scoreboard", { id: scoreboardPeriod })

  return (
    <Panel kind="secondary">
      <VStack gap={24}>
        <SectionTitle>
          Last {scoreboardPeriodInDays[scoreboardPeriod]} days top performers
        </SectionTitle>
        <MatchQuery
          value={query}
          success={(value) => (
            <VStack gap={24}>
              <ScoreboardTable
                myPosition={value.myPosition}
                users={value.users}
              />
              <HStack fullWidth justifyContent="end">
                <LastScoreboardUpdate value={value.syncedAt} />
              </HStack>
            </VStack>
          )}
          error={() => <Text>Something went wrong</Text>}
          pending={() => <Spinner />}
        />
      </VStack>
    </Panel>
  )
}

Pattern Matching with Record Unions

A more interesting use case for pattern matching arises when we work with more complex data structures, such as a record union. For example, consider a Shape type that can represent a circle, rectangle, or triangle. In this context, a record union refers to an object with a single key that uniquely identifies each variant.

type Shape =
  | { circle: { radius: number } }
  | { rectangle: { width: number; height: number } }
  | { triangle: { base: number; height: number } }

To work with record unions—where each variant is a unique key—you can use a utility like matchRecordUnion to dispatch logic based on that key. This avoids manual type checks or nested conditionals, and ensures each handler receives a strongly typed argument for its variant. As a result, your code stays concise, type-safe, and easy to extend.

const getArea = (shape: Shape): number =>
  matchRecordUnion(shape, {
    circle: ({ radius }) => Math.PI * radius * radius,
    rectangle: ({ width, height }) => width * height,
    triangle: ({ base, height }) => 0.5 * base * height,
  })

The Implementation of matchRecordUnion

The implementation of matchRecordUnion requires some TypeScript type gymnastics to properly infer and extract the types involved. It uses conditional types to first determine the keys of the union type, then extract the value associated with a specific key. At runtime, the function first extracts the key from the object, and then calls the appropriate handler with the value associated with that key.

type KeyOfUnion<U> = U extends any ? keyof U : never
type ValueForKey<U, K extends string | number | symbol> =
  U extends Record<K, infer V> ? V : never

export function matchRecordUnion<U, R>(
  value: U,
  handlers: { [K in KeyOfUnion<U>]: (val: ValueForKey<U, K>) => R },
): R {
  const key = Object.keys(value as any)[0] as KeyOfUnion<U>
  return handlers[key]((value as any)[key])
}

Record Union Pattern Matching in React

Just as we transformed the match function into a React component earlier, we can similarly convert matchRecordUnion into a component-based version.

import React from "react"

type KeyOfUnion<U> = U extends any ? keyof U : never
type ValueForKey<U, K extends string | number | symbol> =
  U extends Record<K, infer V> ? V : never

export type MatchRecordUnionProps<U> = {
  value: U
  handlers: {
    [K in KeyOfUnion<U>]: (payload: ValueForKey<U, K>) => React.ReactNode
  }
}

export function MatchRecordUnion<U>({
  value,
  handlers,
}: MatchRecordUnionProps<U>) {
  const key = Object.keys(value as any)[0] as KeyOfUnion<U>
  const content = handlers[key]((value as any)[key])
  return <>{content}</>
}

Advanced Pattern Matching for Different Union Structures

The match and matchRecordUnion utilities, combined with the resolvers pattern discussed earlier, address most real-world pattern matching scenarios in TypeScript. While these patterns handle the majority of use cases, you can adapt them for other type structures when needed. For instance, my toolkit also includes matchDiscriminatedUnion, which I reserve for working with external APIs that provide unions structured as { case: 'foo', value: { ... } } | { case: 'bar', value: { ... } } — where a discriminant field identifies the type and a separate field holds the actual data. Despite having this option available, I typically favor record unions for their more compact representation.

type PropertyKey = string | number | symbol

type DiscriminantValues<U, D extends PropertyKey> =
  U extends Record<D, infer X> ? X : never

type PayloadForCase<
  U,
  D extends PropertyKey,
  V extends PropertyKey,
  K extends PropertyKey,
> = U extends { [p in D]: K } & Record<V, infer R> ? R : never

type HandlerMap<U, D extends PropertyKey, V extends PropertyKey, R> = {
  [K in DiscriminantValues<U, D> & PropertyKey]: (
    payload: PayloadForCase<U, D, V, K>,
  ) => R
}

export function matchDiscriminatedUnion<
  U extends Record<D, any> & Record<V, any>,
  R,
  D extends PropertyKey = PropertyKey,
  V extends PropertyKey = PropertyKey,
>(
  value: U,
  discriminantKey: D,
  valueKey: V,
  handlers: HandlerMap<U, D, V, R>,
): R {
  const key = value[discriminantKey] as DiscriminantValues<U, D> & PropertyKey
  const narrowed = value as Extract<U, { [p in D]: typeof key }>
  return handlers[key](narrowed[valueKey])
}

Type Predicates for Safe Pattern Matching

Let me introduce a small but useful utility function that complements our pattern matching approach. When working with pattern matching, we often need to verify if a string value is one of the possible values in a union type before proceeding with the match operation.

export function isOneOf<T>(item: unknown, items: readonly T[]): item is T {
  return items.includes(item as T)
}

Type-Safe Pattern Matching with External Data

This example demonstrates why isOneOf is essential for type-safe pattern matching with external data. The function returns a boolean indicating if the item exists in the array, but crucially, the item is T return type is a TypeScript type predicate that narrows the type when used in conditionals. In our email provider example, once isOneOf confirms the extracted provider is valid, TypeScript treats it as a member of our const array, ensuring the subsequent match call only receives values it can properly handle and preventing compiler errors from unhandled cases.

import { isOneOf } from "./array/isOneOf"
import { extractEmailProvider } from "./extractEmailProvider"
import { match } from "./match"

const emailProvidersWithClient = [
  "gmail",
  "outlook",
  "hotmail",
  "live",
  "yahoo",
  "protonmail",
  "aol",
  "zoho",
] as const

const outlookInboxLink = "https://outlook.live.com/mail/0/inbox"

export const suggestInboxLink = (
  email: string,
  sender?: string,
): string | undefined => {
  const emailProvider = extractEmailProvider(email)
  if (!emailProvider) return undefined

  if (!isOneOf(emailProvider, emailProvidersWithClient)) return undefined

  return match(emailProvider, {
    gmail: () => {
      const url = "https://mail.google.com/mail/u/0/#search"
      if (!sender) return url

      const searchStr = encodeURIComponent(`from:@${sender}+in:anywhere`)
      return [url, searchStr].join("/")
    },
    outlook: () => outlookInboxLink,
    hotmail: () => outlookInboxLink,
    live: () => outlookInboxLink,
    yahoo: () => "https://mail.yahoo.com/d/folders/1",
    protonmail: () => "https://mail.protonmail.com/u/0/inbox",
    aol: () => "https://mail.aol.com/webmail-std/en-us/inbox",
    zoho: () => "https://mail.zoho.com/zm/#mail/folder/inbox",
  })
}

Conclusion

Pattern matching in TypeScript transforms conditional logic from verbose switch statements into concise, type-safe patterns that catch errors at compile time and scale beautifully as your codebase grows. By embracing these techniques in your projects, you'll write more maintainable code with better type safety while making it easier to handle complex data structures and UI states.