Skip to content

Building matchers

A matcher is assembled with pipe: you start it, add ordered cases, then finish it. 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 }
// Match.type<T>() produces a reusable (shape: Shape) => number function
const area = Match.type<Shape>().pipe(
// event is narrowed to the Circle member inside this branch
Match.tag("Circle", (event) => Math.PI * event.radius ** 2),
Match.tag("Rect", (event) => event.width * event.height),
// 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"

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"

Two combinators extend when for multi-pattern cases:

  • Match.whenOr(patternA, patternB, handler) runs the handler if any of the patterns match.
  • Match.whenAnd(patternA, patternB, handler) runs the handler only if all patterns match.

Match.not(pattern, handler) is the inverse of when: it matches every input except those covered by the pattern.

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 }).

| Predicate | Matches | | ------------------------- | --------------------------------------------------------------- | | Match.string | values of type string | | Match.nonEmptyString | non-empty strings | | Match.number | values of type number | | Match.boolean | values of type boolean | | Match.bigint | values of type bigint | | Match.symbol | values of type symbol | | Match.date | instances of Date | | Match.record | objects keyed by string/symbol with unknown values | | Match.null | the value null | | Match.undefined | the value undefined | | Match.defined | any non-null, non-undefined value | | Match.any | any 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 given class |

import { Match } from "effect"
// PropertyKey is string | number | symbol
const describeKey = Match.type<PropertyKey>().pipe(
Match.when(Match.number, (n) => `number key: ${n}`),
Match.when(Match.string, (s) => `string key: ${s}`),
Match.when(Match.symbol, (s) => `symbol key: ${String(s)}`),
Match.exhaustive
)
console.log(describeKey(42)) // "number key: 42"
console.log(describeKey("name")) // "string key: name"

Effect’s data types (errors, Option, Result, schema variants) all use a _tag field as their discriminator. Match.tag matches against it directly and narrows the handler to that member. You can pass several tags to one 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", () => "…"),
// event is narrowed to the Success member here
Match.tag("Success", (event) => `data: ${event.data}`),
Match.tag("Failure", (event) => `error: ${event.error.message}`),
Match.exhaustive
)
console.log(render({ _tag: "Success", data: "hi" })) // "data: hi"
console.log(render({ _tag: "Loading" })) // "…"

For a flat, switch-like table over tags, Match.tags (and the self-finalizing Match.tagsExhaustive) take an object mapping each tag to its handler:

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(
// tagsExhaustive finalizes the matcher; every tag must appear
Match.tagsExhaustive({
Loading: () => "…",
Success: (event) => `data: ${event.data}`,
Failure: (event) => `error: ${event.error.message}`
})
)
console.log(render({ _tag: "Failure", error: new Error("boom") }))
// Output: "error: boom"
import { Match } from "effect"
// A union discriminated by "type" rather than "_tag"
type Action =
| { readonly type: "increment"; readonly by: number }
| { readonly type: "reset" }
const reduce = Match.type<Action>().pipe(
Match.discriminator("type")("increment", (event) => event.by),
Match.discriminator("type")("reset", () => 0),
Match.exhaustive
)
console.log(reduce({ type: "increment", by: 5 })) // 5

Every matcher must be closed with a finalizer. The choice encodes what should happen when no case matches.

  • Match.exhaustive — compiles only when every possible input is handled; throws at runtime if narrowing was somehow bypassed. Use it for closed unions where a missed case is a bug.
  • Match.orElse(handler) — runs a fallback handler for any unmatched input, so the match need not be exhaustive.
  • Match.option — returns the result as an Option: Option.some(value) on a match, Option.none() otherwise.
  • Match.result — returns a Result: Result.succeed(value) on a match, or Result.fail(input) 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" }
// Not every role is handled — option turns a miss into Option.none()
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" }))
// Output: { _id: 'Option', _tag: 'Some', value: 'full access' }
console.log(access({ role: "viewer" }))
// Output: { _id: 'Option', _tag: 'None' }

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
)