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.
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.
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",
)
)
},
})
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,
}),
),
})
}
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()
}
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()}</>
}
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>
)
}
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
}
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>
)
}
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
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])
}
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}</>
}
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])
}
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)
}
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",
})
}
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.