Skip to content

Building matchers

A matcher is assembled with pipe in three stages: you start it, add ordered cases, then finish it with a finalizer. Cases are evaluated top to bottom and the first match wins, so order matters — put specific cases before catch-alls. The type system narrows the handler argument for each case and tracks the inputs that are still unmatched, which is what makes the Match.exhaustive finalizer safe.

import { Match } from "effect"
type Shape =
| { readonly _tag: "Circle"; readonly radius: number }
| { readonly _tag: "Rect"; readonly width: number; readonly height: number }
// 1. start: Match.type<T>() produces a reusable (shape: Shape) => number
const area = Match.type<Shape>().pipe(
// 2. add cases: shape is narrowed to the Circle member inside this branch
Match.tag("Circle", (shape) => Math.PI * shape.radius ** 2),
Match.tag("Rect", (shape) => shape.width * shape.height),
// 3. finish: drop a tag above and this line stops compiling
Match.exhaustive
)
console.log(area({ _tag: "Circle", radius: 2 })) // => 12.566...
console.log(area({ _tag: "Rect", width: 3, height: 4 })) // => 12

Match.type<T>() builds a reusable matcher: the result is a function you can call with many different inputs. Use it when the same branch table is applied in more than one place, or when you want exhaustiveness checking against a union type.

import { Match } from "effect"
// ┌─── (u: string | number) => string
// ▼
const format = Match.type<string | number>().pipe(
// Match.number / Match.string are built-in type refinements
Match.when(Match.number, (n) => `number: ${n}`),
Match.when(Match.string, (s) => `string: ${s}`),
Match.exhaustive
)
console.log(format(0)) // => "number: 0"
console.log(format("hello")) // => "string: hello"

Match.value(x) classifies a single value right away. The matcher already contains the input, so the finalizer returns the result directly instead of a function. Use it for one-off branching where you don’t need a reusable matcher.

import { Match } from "effect"
const input = { name: "John", age: 30 }
const result = Match.value(input).pipe(
// Object patterns match by comparing the listed fields
Match.when({ name: "John" }, (user) => `${user.name} is ${user.age}`),
// orElse supplies a fallback, so the match need not be exhaustive
Match.orElse(() => "not John")
)
console.log(result) // => "John is 30"

When all you need is a switch-like table over a discriminated union’s _tag, the *Tags constructors skip the pipe/finalizer ceremony entirely. They take an object mapping each tag to a handler and dispatch directly. There are three flavours depending on whether you want an immediate result, a reusable function, or a pipeline finalizer.

Match.valueTags matches one value immediately and returns the handler’s result directly — no finalizer, no function to call later.

import { Match } from "effect"
type Status =
| { readonly _tag: "Success"; readonly data: string }
| { readonly _tag: "Failure"; readonly error: string }
const success: Status = { _tag: "Success", data: "Hello" }
const message = Match.valueTags(success, {
Success: (s) => `ok: ${s.data}`,
Failure: (f) => `err: ${f.error}`
})
console.log(message) // => "ok: Hello"

Match.typeTags<I>() builds a reusable function from the same handler map. It has two overloads: pass a return type (typeTags<I, Ret>()) to force every branch to return Ret, or omit it (typeTags<I>()) to infer the union of all branch results.

import { Match } from "effect"
type Result =
| { readonly _tag: "Success"; readonly data: string }
| { readonly _tag: "Error"; readonly message: string }
| { readonly _tag: "Loading" }
// Pinned return type: every branch must return a string
const formatResult = Match.typeTags<Result, string>()({
Success: (r) => `Data: ${r.data}`,
Error: (r) => `Error: ${r.message}`,
Loading: () => "Loading..."
})
console.log(formatResult({ _tag: "Success", data: "Hi" })) // => "Data: Hi"
// Inferred return type: the union of all branch results
const processResult = Match.typeTags<Result>()({
Success: (r) => ({ type: "ok", value: r.data }),
Error: (r) => ({ type: "error", error: r.message }),
Loading: () => ({ type: "pending" })
})
console.log(processResult({ _tag: "Loading" })) // => { type: "pending" }

The three tag-dispatch entry points are easy to confuse:

APIShapeResult
Match.valueTags(input, handlers)takes the value, immediatethe handler’s return value
Match.typeTags<I>()(handlers)reusable(input: I) => Ret
Match.tagsExhaustive(handlers)a pipeline step (after Match.type/value)finalizes the matcher (see below)

Match.when is the general-purpose case. Its first argument is a pattern, which can be a literal value, a predicate function, or an object whose fields are themselves patterns. The handler runs with the input narrowed to whatever the pattern accepts.

import { Match } from "effect"
const classify = Match.type<{ age: number }>().pipe(
// Field pattern with a predicate: matches when age > 18
Match.when({ age: (age: number) => age > 18 }, (user) => `adult: ${user.age}`),
// Field pattern with a literal: matches when age is exactly 18
Match.when({ age: 18 }, () => "just eligible"),
Match.orElse((user) => `minor: ${user.age}`)
)
console.log(classify({ age: 20 })) // => "adult: 20"
console.log(classify({ age: 18 })) // => "just eligible"
console.log(classify({ age: 4 })) // => "minor: 4"

Runs the handler if any of the supplied patterns match. The handler’s input is narrowed to the union of the patterns.

import { Match } from "effect"
type ErrorType =
| { readonly _tag: "NetworkError" }
| { readonly _tag: "TimeoutError" }
| { readonly _tag: "ValidationError"; readonly field: string }
const handle = Match.type<ErrorType>().pipe(
Match.whenOr(
{ _tag: "NetworkError" },
{ _tag: "TimeoutError" },
() => "Retry the request"
),
Match.when({ _tag: "ValidationError" }, (e) => `Invalid: ${e.field}`),
Match.exhaustive
)
console.log(handle({ _tag: "NetworkError" })) // => "Retry the request"
console.log(handle({ _tag: "ValidationError", field: "email" })) // => "Invalid: email"

Runs the handler only if all supplied patterns match. The patterns are intersected, so the handler sees a value satisfying every one.

import { Match } from "effect"
type User = { readonly age: number; readonly role: "admin" | "user" }
const check = Match.type<User>().pipe(
Match.whenAnd(
{ age: (n: number) => n >= 18 },
{ role: "admin" },
() => "Admin access granted"
),
Match.orElse(() => "Access denied")
)
console.log(check({ age: 20, role: "admin" })) // => "Admin access granted"
console.log(check({ age: 20, role: "user" })) // => "Access denied"

The inverse of when: it matches every input except those covered by the pattern. Excluded values bypass the handler and continue through later cases.

import { Match } from "effect"
const greet = Match.type<string | number>().pipe(
// Matches anything that is not the literal "hi"
Match.not("hi", () => "ok"),
Match.orElse(() => "fallback")
)
console.log(greet("hello")) // => "ok"
console.log(greet("hi")) // => "fallback"

Patterns can use these refinements to match by type instead of by value. They compose inside object patterns too (e.g. { a: Match.number }). Each is shown with a short example below.

RefinementMatches
Match.stringvalues of type string
Match.nonEmptyStringnon-empty strings
Match.numbervalues of type number (incl. NaN, infinities)
Match.booleanvalues of type boolean
Match.bigintvalues of type bigint
Match.symbolvalues of type symbol
Match.dateinstances of Date
Match.recordnon-null, non-array objects
Match.nullthe literal null
Match.undefinedthe literal undefined
Match.definedany non-null, non-undefined value
Match.anyany value, with no narrowing
Match.is(...values)one of the given literal values, e.g. Match.is("a", 42, true)
Match.instanceOf(Class)instances of a class, with type-safe narrowing
Match.instanceOfUnsafe(Class)instances of a class, without the narrowing guarantee

Refines unknown to string.

import { Match } from "effect"
const f = Match.type<unknown>().pipe(
Match.when(Match.string, (s) => s.toUpperCase()),
Match.orElse(() => "not a string")
)
console.log(f("hi")) // => "HI"
console.log(f(42)) // => "not a string"

Matches strings of length greater than zero (whitespace-only strings still count as non-empty).

import { Match } from "effect"
const f = Match.type<string>().pipe(
Match.when(Match.nonEmptyString, (s) => `valid: ${s}`),
Match.orElse(() => "empty")
)
console.log(f("hello")) // => "valid: hello"
console.log(f("")) // => "empty"

Refines unknown to number, including NaN, Infinity, and -Infinity.

import { Match } from "effect"
const f = Match.type<unknown>().pipe(
Match.when(Match.number, (n) => `number: ${n}`),
Match.orElse(() => "not a number")
)
console.log(f(3.14)) // => "number: 3.14"
console.log(f("3.14")) // => "not a number"

Refines unknown to the primitive boolean values true / false.

import { Match } from "effect"
const f = Match.type<unknown>().pipe(
Match.when(Match.boolean, (b) => (b ? "yes" : "no")),
Match.orElse(() => "not a boolean")
)
console.log(f(true)) // => "yes"
console.log(f(0)) // => "not a boolean"

Refines unknown to bigint.

import { Match } from "effect"
const f = Match.type<unknown>().pipe(
Match.when(Match.bigint, (b) => `bigint: ${b}`),
Match.orElse(() => "not a bigint")
)
console.log(f(123n)) // => "bigint: 123"
console.log(f(123)) // => "not a bigint"

Refines unknown to symbol.

import { Match } from "effect"
const f = Match.type<unknown>().pipe(
Match.when(Match.symbol, (s) => `symbol: ${String(s)}`),
Match.orElse(() => "not a symbol")
)
console.log(f(Symbol("x"))) // => "symbol: Symbol(x)"
console.log(f("x")) // => "not a symbol"

Matches Date instances only — not date strings or numeric timestamps.

import { Match } from "effect"
const f = Match.type<unknown>().pipe(
Match.when(Match.date, (d) => d.getFullYear()),
Match.orElse(() => "not a Date")
)
console.log(f(new Date("2024-01-01"))) // => 2024
console.log(f("2024-01-01")) // => "not a Date"

Backed by Predicate.isObject: matches non-null values whose runtime type is "object" and that are not arrays. Note that Date, RegExp, and class instances also pass — use instanceOf to distinguish them.

import { Match } from "effect"
const f = Match.type<unknown>().pipe(
Match.when(Match.record, (obj) => Object.keys(obj).length),
Match.orElse(() => "not a record")
)
console.log(f({ a: 1, b: 2 })) // => 2
console.log(f([1, 2, 3])) // => "not a record"

Matches any value that is neither null nor undefined.

import { Match } from "effect"
const f = Match.type<string | null | undefined>().pipe(
Match.when(Match.defined, (v) => `defined: ${v}`),
Match.orElse(() => "nullish")
)
console.log(f("hi")) // => "defined: hi"
console.log(f(null)) // => "nullish"

Matches only the literal null. Backed by Predicate.isNull.

import { Match } from "effect"
const f = Match.type<string | null | undefined>().pipe(
Match.when(Match.null, () => "was null"),
Match.orElse((v) => `other: ${v}`)
)
console.log(f(null)) // => "was null"
console.log(f("hi")) // => "other: hi"

Matches only the literal undefined. Backed by Predicate.isUndefined.

import { Match } from "effect"
const f = Match.type<string | null | undefined>().pipe(
Match.when(Match.undefined, () => "was undefined"),
Match.orElse((v) => `other: ${v}`)
)
console.log(f(undefined)) // => "was undefined"
console.log(f("hi")) // => "other: hi"

Matches every input with no narrowing — including null, undefined, objects, and functions. Put it last, since the first matching case wins.

import { Match } from "effect"
const f = Match.type<unknown>().pipe(
Match.when(Match.string, (s) => `string: ${s}`),
Match.when(Match.any, (v) => `other: ${typeof v}`),
Match.exhaustive
)
console.log(f("hi")) // => "string: hi"
console.log(f(null)) // => "other: object"

Matches one of a set of literal primitive (or null) values.

import { Match } from "effect"
const f = Match.type<string | number>().pipe(
Match.when(Match.is("ok", 200), () => "success"),
Match.when(Match.is("error", 500), () => "failure"),
Match.orElse((v) => `unknown: ${v}`)
)
console.log(f(200)) // => "success"
console.log(f("error")) // => "failure"
console.log(f("pending")) // => "unknown: pending"

Matches instances of a constructor and narrows the handler argument to the instance type.

import { Match } from "effect"
const f = Match.type<unknown>().pipe(
Match.when(Match.instanceOf(Map), (m) => `map size: ${m.size}`),
Match.when(Match.instanceOf(Error), (e) => `error: ${e.message}`),
Match.orElse(() => "other")
)
console.log(f(new Map([["a", 1]]))) // => "map size: 1"
console.log(f(new Error("boom"))) // => "error: boom"

Same runtime instanceof check as Match.instanceOf, but it does not remove the matched type from the remaining/unmatched set. Use it when you need the looser refinement and are willing to narrow the handler argument manually; prefer Match.instanceOf for normal type-safe matching.

import { Match } from "effect"
class CustomError extends Error {
constructor(message: string, readonly code: number) {
super(message)
}
}
const f = Match.type<unknown>().pipe(
Match.when(Match.instanceOfUnsafe(CustomError), (err) => {
// narrowing is up to you here
const e = err as CustomError
return `code ${e.code}: ${e.message}`
}),
Match.orElse(() => "not a CustomError")
)
console.log(f(new CustomError("nope", 404))) // => "code 404: nope"
console.log(f("x")) // => "not a CustomError"

Effect’s data types (errors, Option, Result, schema variants) all use a _tag field as their discriminator. The tag family matches against it directly and narrows the handler to that member.

Matches one or more _tag values with a single handler.

import { Match } from "effect"
type RemoteData =
| { readonly _tag: "Loading" }
| { readonly _tag: "Success"; readonly data: string }
| { readonly _tag: "Failure"; readonly error: Error }
| { readonly _tag: "Cancelled" }
const render = Match.type<RemoteData>().pipe(
// One handler can cover multiple tags
Match.tag("Loading", "Cancelled", () => "…"),
Match.tag("Success", (e) => `data: ${e.data}`),
Match.tag("Failure", (e) => `error: ${e.error.message}`),
Match.exhaustive
)
console.log(render({ _tag: "Success", data: "hi" })) // => "data: hi"
console.log(render({ _tag: "Loading" })) // => "…"

Matches any member whose _tag begins with a given prefix — handy for hierarchical or namespaced tags like "A.A".

import { Match } from "effect"
const f = Match.type<{ _tag: "A" } | { _tag: "B" } | { _tag: "A.A" }>().pipe(
Match.tagStartsWith("A", () => 1 as const),
Match.tagStartsWith("B", () => 2 as const),
Match.orElse(() => 3 as const)
)
console.log(f({ _tag: "A" })) // => 1
console.log(f({ _tag: "A.A" })) // => 1
console.log(f({ _tag: "B" })) // => 2

Takes an object mapping each _tag to a handler. It does not finalize the matcher — handlers may be partial, and the pipeline continues, so you still need a finalizer afterwards.

import { Match } from "effect"
type RemoteData =
| { readonly _tag: "Loading" }
| { readonly _tag: "Success"; readonly data: string }
| { readonly _tag: "Failure"; readonly error: Error }
const render = Match.type<RemoteData>().pipe(
// Handle only some tags here…
Match.tags({
Success: (e) => `data: ${e.data}`,
Failure: (e) => `error: ${e.error.message}`
}),
// …then close the remaining ones
Match.orElse(() => "…")
)
console.log(render({ _tag: "Loading" })) // => "…"

The finalizing variant of Match.tags: every _tag must have a handler, so it closes the matcher directly — no Match.exhaustive needed.

import { Match } from "effect"
type RemoteData =
| { readonly _tag: "Loading" }
| { readonly _tag: "Success"; readonly data: string }
| { readonly _tag: "Failure"; readonly error: Error }
const render = Match.type<RemoteData>().pipe(
// Omit a tag and this stops compiling
Match.tagsExhaustive({
Loading: () => "…",
Success: (e) => `data: ${e.data}`,
Failure: (e) => `error: ${e.error.message}`
})
)
console.log(render({ _tag: "Failure", error: new Error("boom") }))
// => "error: boom"

The discriminator family mirrors the tag family exactly, but for any field you name instead of the hard-wired _tag. Use it for unions discriminated by type, kind, status, and so on.

Matches one or more exact values of a named discriminator field.

import { Match } from "effect"
type Action =
| { readonly type: "increment"; readonly by: number }
| { readonly type: "decrement"; readonly by: number }
| { readonly type: "reset" }
const reduce = Match.type<Action>().pipe(
Match.discriminator("type")("increment", "decrement", (a) => a.by),
Match.discriminator("type")("reset", () => 0),
Match.exhaustive
)
console.log(reduce({ type: "increment", by: 5 })) // => 5
console.log(reduce({ type: "reset" })) // => 0

Matches members whose discriminator field starts with a prefix — the tagStartsWith equivalent for arbitrary fields.

import { Match } from "effect"
const f = Match.type<{ type: "A" } | { type: "B" } | { type: "A.A" }>().pipe(
Match.discriminatorStartsWith("type")("A", () => 1 as const),
Match.discriminatorStartsWith("type")("B", () => 2 as const),
Match.orElse(() => 3 as const)
)
console.log(f({ type: "A" })) // => 1
console.log(f({ type: "A.A" })) // => 1
console.log(f({ type: "B" })) // => 2

An object map of discriminator value to handler, the non-finalizing counterpart of Match.discriminator (and the arbitrary-field version of Match.tags).

import { Match } from "effect"
type Shape =
| { readonly kind: "circle"; readonly r: number }
| { readonly kind: "square"; readonly side: number }
const area = Match.type<Shape>().pipe(
Match.discriminators("kind")({
circle: (c) => Math.PI * c.r ** 2,
square: (s) => s.side ** 2
}),
Match.orElse(() => 0)
)
console.log(area({ kind: "square", side: 3 })) // => 9

The finalizing object-map variant: every discriminator value must be handled, so the matcher is closed directly (the arbitrary-field version of Match.tagsExhaustive).

import { Match } from "effect"
type Shape =
| { readonly kind: "circle"; readonly r: number }
| { readonly kind: "square"; readonly side: number }
const area = Match.type<Shape>().pipe(
Match.discriminatorsExhaustive("kind")({
circle: (c) => Math.PI * c.r ** 2,
square: (s) => s.side ** 2
})
)
console.log(area({ kind: "circle", r: 1 })) // => 3.14159...

Every matcher pipeline must be closed with a finalizer. The choice encodes what should happen when no case matches. (The *Exhaustive and *Tags helpers above finalize on their own.)

Compiles only when every possible input is handled, then produces the matcher function (or, for Match.value, the result). Throws at runtime if narrowing was somehow bypassed. Use it for closed unions where a missed case is a bug.

import { Match } from "effect"
const f = Match.type<"a" | "b">().pipe(
Match.when("a", () => 1),
Match.when("b", () => 2),
Match.exhaustive
)
console.log(f("a")) // => 1

Runs a fallback handler for any unmatched input, so the match need not be exhaustive. The handler receives the remaining (unmatched) input.

import { Match } from "effect"
const f = Match.type<string | number>().pipe(
Match.when("a", () => "ok"),
Match.orElse((v) => `fallback: ${v}`)
)
console.log(f("a")) // => "ok"
console.log(f("b")) // => "fallback: b"

Finalizes a matcher that you believe handles every case, without requiring a fallback handler or compile-time exhaustiveness proof: if an unmatched value reaches it at runtime, it throws. Unlike Match.exhaustive, it does not require the type system to have proven the union empty — so reach for exhaustive when you want the compiler to catch the missing case, and orElseAbsurd only when an unmatched input is genuinely impossible.

import { Match } from "effect"
const f = Match.type<"a" | "b">().pipe(
Match.when("a", () => "Found A"),
Match.when("b", () => "Found B"),
Match.orElseAbsurd
)
console.log(f("a")) // => "Found A"
// f("c" as any) // throws at runtime

Returns the result as an Option: Option.some(value) on a match, Option.none() otherwise.

import { Match } from "effect"
type User = { readonly role: "admin" | "editor" | "viewer" }
const access = Match.type<User>().pipe(
Match.when({ role: "admin" }, () => "full access"),
Match.when({ role: "editor" }, () => "can edit"),
Match.option
)
console.log(access({ role: "admin" }))
// => { _id: 'Option', _tag: 'Some', value: 'full access' }
console.log(access({ role: "viewer" }))
// => { _id: 'Option', _tag: 'None' }

Returns a Result: Result.succeed(value) on a match, or a failure carrying the unmatched input. This is the v4 replacement for the v3 Match.either finalizer.

import { Match } from "effect"
type User = { readonly role: "admin" | "editor" | "viewer" }
const getRole = Match.type<User>().pipe(
Match.when({ role: "admin" }, () => "full access"),
Match.when({ role: "editor" }, () => "can edit"),
Match.result
)
console.log(getRole({ role: "admin" }))
// => { _id: 'Result', _tag: 'Success', value: 'full access' }
console.log(getRole({ role: "viewer" }))
// => { _id: 'Result', _tag: 'Failure', failure: { role: 'viewer' } }

By default the result type is the union of every branch’s return type. Call Match.withReturnType<T>() first to force every branch to return T, so a branch that returns the wrong type is rejected where it is defined.

import { Match } from "effect"
const match = Match.type<{ a: number } | { b: string }>().pipe(
// Must come first in the pipeline
Match.withReturnType<string>(),
// @ts-expect-error — a is a number, not a string
Match.when({ a: Match.number }, (_) => _.a),
Match.when({ b: Match.string }, (_) => _.b),
Match.exhaustive
)

A few exported types describe the matcher itself. You rarely name them by hand, but they appear in inferred signatures and error messages:

  • Matcher — the union of the two concrete matcher kinds.
  • TypeMatcher — produced by Match.type<T>(); finalizes to a reusable function.
  • ValueMatcher — produced by Match.value(x); carries the input and finalizes to a result.
  • Case — a single accumulated case, defined as When | Not (the positive and negative case shapes). SafeRefinement is the type behind the built-in refinements like Match.string and Match.is.