Effect Pattern Matching Skill
Use this skill when working with discriminated unions, ADTs, conditional logic, or any type that uses _tag discrimination. Pattern matching provides exhaustive, type-safe alternatives to imperative conditionals.
Effect Source Reference
The Effect v4 source is available at ~/.local/share/opencode/repos/github.com/Effect-TS/effect@main/.
Browse and read files there directly to look up APIs, types, and implementations.
Reference this for:
- Match source:
packages/effect/src/Match.ts - Data source:
packages/effect/src/Data.ts - Full Schema API:
packages/effect/SCHEMA.md - Effect source:
packages/effect/src/
Core Philosophy
Pattern matching over imperative conditionals:
- Exhaustive by default (compiler enforces all cases)
- Type-safe refinement in each branch
- Declarative, not imperative
- Pipeline-friendly composition
Schema-First Matching (rc.112)
For domain/wire models, prefer class variants combined with
Schema.Union([...]).pipe(Schema.toTaggedUnion('kind')). Use .match for
exhaustiveness, .guards for narrowing decoded values, and .matchOrElse for
intentional partial matching. Direct Schema.TaggedUnion creates canonical
_tag object variants. Both expose data-first and data-last matchOrElse forms;
the toTaggedUnion fallback excludes handled variants, while direct
Schema.TaggedUnion.matchOrElse types its fallback as the full union.
import * as Schema from 'effect/Schema';
const State = Schema.TaggedUnion({
Loading: {},
Ready: { data: Schema.Array(Schema.String) },
Failed: { message: Schema.String }
});
type State = typeof State.Type;
const getData = State.matchOrElse({ Ready: (state) => state.data }, () => []);
const label = State.match({
Loading: () => 'Loading',
Ready: ({ data }) => `${data.length} items`,
Failed: ({ message }) => message
});
TypeScript correctly narrows literal discriminator checks. Prefer these helpers
for exhaustive, reusable branching, not because manual checks cannot narrow.
For the full class-based lifecycle example see effect-domain-modeling.
Pattern 1: Data.TaggedEnum for Trusted ADTs
Use Data.TaggedEnum for trusted, non-schema ADTs. It supplies constructors and
tag checks but does not parse unknown input; avoid a parallel Data model for an
existing schema union.
The Problem: Manual Tagged Unions
// ❌ WRONG - Manual tagged union
type WalletState =
| { readonly _tag: 'Disconnected' }
| { readonly _tag: 'Connecting' }
| { readonly _tag: 'Connected'; readonly address: string }
| { readonly _tag: 'Error'; readonly message: string };
// Manual constructors - verbose and error-prone
const disconnected = (): WalletState => ({ _tag: 'Disconnected' });
const connecting = (): WalletState => ({ _tag: 'Connecting' });
const connected = (address: string): WalletState => ({
_tag: 'Connected',
address
});
const error = (message: string): WalletState => ({ _tag: 'Error', message });
// No built-in pattern matching
// No type guards
// No exhaustiveness checking
The Solution: Data.TaggedEnum
// ✅ CORRECT - TaggedEnum with constructors + $match + $is
import { Data } from 'effect';
type WalletState = Data.TaggedEnum<{
Disconnected: {};
Connecting: {};
Connected: { readonly address: string };
Error: { readonly message: string };
}>;
const WalletState = Data.taggedEnum<WalletState>();
/**
* WalletState now provides:
* - WalletState.Disconnected() - Constructor
* - WalletState.Connecting() - Constructor
* - WalletState.Connected({ address }) - Constructor
* - WalletState.Error({ message }) - Constructor
* - WalletState.$match(state, { ... }) - Pattern matching
* - WalletState.$is("Connected")(state) - Type guard (`_tag` check only)
*/
// Usage
const state = WalletState.Connected({ address: '0x123' });
// Pattern match
const display = WalletState.$match(state, {
Disconnected: () => 'Please connect wallet',
Connecting: () => 'Connecting...',
Connected: ({ address }) => `Connected: ${address}`,
Error: ({ message }) => `Error: ${message}`
});
// Type guard
if (WalletState.$is('Connected')(state)) {
console.log(state.address); // Type-safe access
}
Caveat:
Data.$is(tag)/TaggedEnum.$is(tag)only checks the_tagfield, not the full structure. Use it for values produced by your constructors; validate untrusted input withSchemabefore relying on$is.
Benefits of Data.TaggedEnum
- Automatic constructors - No manual factory functions
- Automatic $match - Exhaustive pattern matching built-in
- Automatic $is - Type-safe guards for each variant
- Type inference - Compiler knows all variants
- Compile-time exhaustiveness - Forget a case? Compiler error
When to Use Data.TaggedEnum
- State machines: Connection states, loading states, workflow states
- Domain events: UserLoggedIn, UserLoggedOut, SessionExpired
- Command types: CreateUser, UpdateUser, DeleteUser
- Result types: Success, Failure, Pending
- Any discriminated union with multiple variants
Pattern 2: Avoid Effect.result + _tag Checks
Use Effect.match instead of Effect.result with manual tag checks.
The Problem: Effect.result with Manual Checks
// ❌ WRONG - Effect.result with manual _tag checks
import { Effect, Result, Schema } from 'effect';
declare const User: { name: string; id: string };
type User = typeof User;
class NotFound extends Schema.TaggedError<NotFound>()('NotFound', {
id: Schema.String
}) {}
const getUser = (id: string): Effect.Effect<User, NotFound> =>
Effect.fail(new NotFound({ id }));
const program = Effect.gen(function* () {
const result = yield* Effect.result(getUser('123'));
// Manual tag checking - not exhaustive
if (result._tag === 'Failure') {
console.error(`User not found: ${result.failure.id}`);
return null;
}
return result.success;
});
Problems:
- Not exhaustive (could forget Success case)
- Verbose and imperative
- Breaks pipeline style
- Manual unwrapping of Either
The Solution: Effect.match
// ✅ CORRECT - Effect.match for declarative error handling
import { Effect, Schema } from 'effect';
declare const User: { name: string; id: string };
type User = typeof User;
class NotFound extends Schema.TaggedError<NotFound>()('NotFound', {
id: Schema.String
}) {}
const getUser = (id: string): Effect.Effect<User, NotFound> =>
Effect.fail(new NotFound({ id }));
const program = getUser('123').pipe(
Effect.match({
onFailure: (error) => {
console.error(`User not found: ${error.id}`);
return null;
},
onSuccess: (user) => user
})
);
Benefits:
- Exhaustive (must handle both cases)
- Declarative and pipeline-friendly
- No manual Either unwrapping
- Type-safe refinement in each branch
Effect.match Variants
import { Effect, Cause } from 'effect';
declare const effect: Effect.Effect<unknown, unknown, unknown>;
declare function handleError(error: unknown): unknown;
declare function handleSuccess(value: unknown): unknown;
declare function handleCause(cause: Cause.Cause<unknown>): unknown;
// Basic match - transform both success and failure
Effect.match(effect, {
onFailure: (error) => handleError(error),
onSuccess: (value) => handleSuccess(value)
});
// matchEffect - return Effects from handlers
Effect.matchEffect(effect, {
onFailure: (error) => Effect.logError(error).pipe(Effect.as(null)),
onSuccess: (value) => Effect.succeed(value)
});
// matchCause - match on full Cause (errors + defects + interrupts)
Effect.matchCause(effect, {
onFailure: (cause) => handleCause(cause),
onSuccess: (value) => value
});
// matchCauseEffect - Cause matching with Effect handlers
Effect.matchCauseEffect(effect, {
onFailure: (cause) => Effect.logError(cause).pipe(Effect.as(null)),
onSuccess: (value) => Effect.succeed(value)
});
Pattern 3: Use $match for Exhaustive Pattern Matching
Use TaggedEnum.$match for exhaustive, type-safe pattern matching.
The Problem: if/else Chains
// ❌ WRONG - if/else chains, not exhaustive
import { Data } from 'effect';
type Status = Data.TaggedEnum<{
Active: {};
Expired: {};
Revoked: {};
}>;
const Status = Data.taggedEnum<Status>();
const getColor = (status: Status): string => {
if (status._tag === 'Active') {
return 'green';
} else if (status._tag === 'Expired') {
return 'yellow';
}
// Forgot "Revoked" - no compiler error!
return 'gray';
};
Problems:
- Not exhaustive (easy to forget cases)
- Compiler doesn't enforce completeness
- Imperative style
- Hard to refactor when adding variants
The Solution: $match
// ✅ CORRECT - $match with exhaustive checking
import { Data } from 'effect';
type Status = Data.TaggedEnum<{
Active: {};
Expired: {};
Revoked: {};
}>;
const Status = Data.taggedEnum<Status>();
const getColor = (status: Status): string =>
Status.$match(status, {
Active: () => 'green',
Expired: () => 'yellow',
Revoked: () => 'red'
// Compiler error if any case is missing!
});
Benefits:
- Exhaustive - Compiler enforces all cases
- Type-safe - Each handler gets refined type
- Declarative - Clear mapping from variant to result
- Refactor-safe - Add variant? Compiler finds all matches to update
$match with Data Access
type AsyncState = Data.TaggedEnum<{
Idle: {};
Loading: {};
Success: { readonly data: string };
Failure: { readonly error: string };
}>;
const AsyncState = Data.taggedEnum<AsyncState>();
const display = (state: AsyncState): string =>
AsyncState.$match(state, {
Idle: () => 'Not started',
Loading: () => 'Loading...',
Success: ({ data }) => `Loaded: ${data}`,
Failure: ({ error }) => `Error: ${error}`
});
Nested Pattern Matching
type Request = Data.TaggedEnum<{
Pending: {};
Approved: { readonly by: string };
Rejected: { readonly reason: string };
}>;
const Request = Data.taggedEnum<Request>();
type Workflow = Data.TaggedEnum<{
Draft: { readonly request: Request };
Submitted: { readonly request: Request };
Completed: {};
}>;
const Workflow = Data.taggedEnum<Workflow>();
const getStatus = (workflow: Workflow): string =>
Workflow.$match(workflow, {
Draft: ({ request }) =>
Request.$match(request, {
Pending: () => 'Draft - Pending',
Approved: ({ by }) => `Draft - Approved by ${by}`,
Rejected: ({ reason }) => `Draft - Rejected: ${reason}`
}),
Submitted: ({ request }) =>
Request.$match(request, {
Pending: () => 'Submitted - Awaiting approval',
Approved: ({ by }) => `Submitted - Approved by ${by}`,
Rejected: ({ reason }) => `Submitted - Rejected: ${reason}`
}),
Completed: () => 'Completed'
});
Pattern 4: Use $is for Single-Case Type Guards
Use TaggedEnum.$is instead of manual _tag checks. It only checks _tag, so validate untrusted input with Schema before using it as a structural guarantee.
The Problem: Manual _tag Checks
// ❌ WRONG - Manual tag checking
import { Data } from 'effect';
type Status = Data.TaggedEnum<{
Active: {};
Expired: {};
}>;
const Status = Data.taggedEnum<Status>();
// Verbose and repetitive
const status = Status.Active();
if (status._tag === 'Active') {
console.log('Active!');
}
// Hard to use in Array methods
const items: Status[] = [Status.Active(), Status.Expired()];
const activeItems = items.filter((item) => item._tag === 'Active');
The Solution: $is Type Guards
// ✅ CORRECT - $is for type-safe guards
import { Data, Array, pipe } from 'effect';
type Status = Data.TaggedEnum<{
Active: {};
Expired: {};
}>;
const Status = Data.taggedEnum<Status>();
const status = Status.Active();
// Clean, declarative guard
if (Status.$is('Active')(status)) {
console.log('Active!');
}
// Perfect for Array methods
const items: Status[] = [Status.Active(), Status.Expired()];
const activeItems = items.filter(Status.$is('Active'));
// Pipeline-friendly
const hasActive = pipe(items, Array.some(Status.$is('Active')));
// Multiple guards
const activeOrExpired = items.filter(
(item) => Status.$is('Active')(item) || Status.$is('Expired')(item)
);
$is in Effect Pipelines
import { Data, pipe } from 'effect';
type LoadState = Data.TaggedEnum<{
Loading: {};
Ready: { readonly data: string[] };
Error: { readonly message: string };
}>;
const LoadState = Data.taggedEnum<LoadState>();
const getData = LoadState.$match({
Loading: () => [],
Ready: ({ data }) => data,
Error: () => []
});
// A guard returns boolean; piping through $is would lose the original value.
Pattern 5: Use Option.match Instead of _tag Checks
Use Option.match instead of manual ._tag checks on Options.
The Problem: Manual Option Tag Checks
// ❌ WRONG - Manual Option._tag checks
import { Option } from 'effect';
type User = { name: string; id: string };
const maybeUser: Option.Option<User> = Option.some({
name: 'Alice',
id: '123'
});
// Imperative and verbose
if (maybeUser._tag === 'Some') {
console.log(maybeUser.value.name);
} else {
console.log('No user');
}
The Solution: Option.match
// ✅ CORRECT - Option.match
import { Option, pipe } from 'effect';
type User = { name: string; id: string };
const maybeUser: Option.Option<User> = Option.some({
name: 'Alice',
id: '123'
});
const display = Option.match(maybeUser, {
onNone: () => 'No user',
onSome: (user) => user.name
});
// In pipelines
const name = pipe(
maybeUser,
Option.match({
onNone: () => 'Guest',
onSome: (user) => user.name
})
);
Option Pattern Matching Variants
import { Option, pipe } from 'effect';
declare const option: Option.Option<string>;
declare const defaultValue: string;
declare function transform(value: string): string;
declare function predicate(value: string): boolean;
// Basic match
Option.match(option, {
onNone: () => defaultValue,
onSome: (value) => transform(value)
});
// getOrElse - simpler for just default value
Option.getOrElse(option, () => defaultValue);
// map + getOrElse pattern
pipe(
option,
Option.map(transform),
Option.getOrElse(() => defaultValue)
);
// filter + match
pipe(
option,
Option.filter(predicate),
Option.match({
onNone: () => 'Filtered out or was None',
onSome: (value) => `Matched: ${value}`
})
);
Pattern 6: Use Match.typeTags for Schema Unions
For Schema-based unions, use Match.typeTags for pattern matching.
Schema Union Pattern Matching
import { Schema, Match } from 'effect';
// Schema-based tagged structs
const Admin = Schema.TaggedStruct('Admin', {
id: Schema.String,
permissions: Schema.Array(Schema.String)
});
const Customer = Schema.TaggedStruct('Customer', {
id: Schema.String,
tier: Schema.Literals(['free', 'premium'])
});
const User = Schema.Union([Admin, Customer]);
type User = Schema.Schema.Type<typeof User>;
// Match.typeTags for Schema unions
const getPermissions = Match.typeTags<User>()({
Admin: ({ permissions }) => permissions,
Customer: ({ tier }) => (tier === 'premium' ? ['read'] : [])
});
const user: User = {
_tag: 'Admin' as const,
id: '1',
permissions: ['read', 'write']
};
const perms = getPermissions(user); // ["read", "write"]
Match.typeTags Pattern
import { Match, Data } from 'effect';
type UnionType = Data.TaggedEnum<{
VariantA: { field: string };
VariantB: { other: number };
}>;
declare const value: UnionType;
declare function handleA(data: { field: string }): string;
declare function handleB(data: { other: number }): string;
// Create matcher function
const match = Match.typeTags<UnionType>();
// Use with handlers object
const result = match({
VariantA: (data) => handleA(data),
VariantB: (data) => handleB(data)
})(value);
// Or create matcher and apply later
const matcher = match({
VariantA: (data) => handleA(data),
VariantB: (data) => handleB(data)
});
const result2 = matcher(value);
Pattern 7: Match.fn for Selector-Based Functions
Use Match.fn(selector) when a reusable function takes several arguments or should match a projection of its input. The selector chooses the value matched by when / tag / other cases, while the compiled matcher preserves the selector's original argument list.
Case handlers receive the narrowed selected value first, followed by all original selector arguments:
import { Match } from 'effect';
type Todo = {
readonly status: 'Active' | 'Completed';
readonly title: string;
};
const formatTodo = Match.fn((prefix: string, todo: Todo) => todo.status).pipe(
Match.when(
'Active',
(_status, prefix, todo) => `${prefix}: active ${todo.title}`
),
Match.when(
'Completed',
(_status, prefix, todo) => `${prefix}: completed ${todo.title}`
),
Match.exhaustive
);
formatTodo('Todo', { status: 'Active', title: 'Write tests' });
// "Todo: active Write tests"
Match.exhaustive, Match.orElse, Match.option, and Match.result all compile a Match.fn matcher to a function with the selector's original parameters. Prefer Match.type<I>() when the matched value itself is the function's only input; use Match.fn when matching one argument, a derived property, or another projection while retaining surrounding arguments.
Pattern 8: Loadable.match for Async State
Use Loadable.match for async state pattern matching.
Loadable Pattern
import { Loadable } from '@/typeclass/Loadable';
type User = { name: string; id: string };
declare const Spinner: () => JSX.Element;
declare const UserProfile: (props: { user: User }) => JSX.Element;
declare const ErrorDisplay: (props: { error: Error }) => JSX.Element;
type UserData = Loadable.Loadable<User>;
const display = (data: UserData): JSX.Element =>
Loadable.match(data, {
onPending: () => <Spinner />,
onReady: (user) => <UserProfile user={user} />
});
// With error state
type UserDataWithError = Loadable.LoadableWithError<User, Error>;
const displayWithError = (data: UserDataWithError): JSX.Element =>
Loadable.matchWithError(data, {
onPending: () => <Spinner />,
onReady: (user) => <UserProfile user={user} />,
onError: (error) => <ErrorDisplay error={error} />
});
Testability: Effect Services
When pattern matching involves non-deterministic operations, use Effect services.
The Problem: Untestable Direct Calls
// ❌ WRONG - untestable
import { Data } from 'effect';
type State = Data.TaggedEnum<{
Active: {};
Expired: {};
}>;
const State = Data.taggedEnum<State>();
const processState = (state: State): string =>
State.$match(state, {
Active: () => `Active at ${Date.now()}`,
Expired: () => `Expired at ${Date.now()}`
});
The Solution: Effect Services
// ✅ CORRECT - testable with Clock service
import { Clock, Effect, Data } from 'effect';
import { TestClock } from 'effect/testing';
type State = Data.TaggedEnum<{
Active: {};
Expired: {};
}>;
const State = Data.taggedEnum<State>();
const processState = (state: State): Effect.Effect<string> =>
Effect.gen(function* () {
const now = yield* Clock.currentTimeMillis;
return State.$match(state, {
Active: () => `Active at ${now}`,
Expired: () => `Expired at ${now}`
});
});
// In tests, use TestClock for deterministic time
const testProgram = processState(State.Active()).pipe(
Effect.provide(TestClock.layer())
);
Random Values in Pattern Matching
// ❌ WRONG - untestable
import { Data } from 'effect';
type User = Data.TaggedEnum<{
Admin: {};
Customer: {};
}>;
const User = Data.taggedEnum<User>();
const assignColor = (user: User): string =>
User.$match(user, {
Admin: () => 'red',
Customer: () => (Math.random() > 0.5 ? 'blue' : 'green')
});
// ✅ CORRECT - testable with Random service
import { Random, Effect } from 'effect';
const assignColorTestable = (user: User): Effect.Effect<string> =>
User.$match(user, {
Admin: () => Effect.succeed('red'),
Customer: () =>
Effect.gen(function* () {
const rand = yield* Random.next;
return rand > 0.5 ? 'blue' : 'green';
})
});
Complete Example: Wallet Connection State Machine
import { Data, Effect, Clock } from 'effect';
// Define state machine with TaggedEnum
type WalletState = Data.TaggedEnum<{
Disconnected: {};
Connecting: { readonly startedAt: number };
Connected: {
readonly address: string;
readonly connectedAt: number;
};
Error: {
readonly message: string;
readonly occurredAt: number;
};
}>;
const WalletState = Data.taggedEnum<WalletState>();
// State transitions
const connect = (): Effect.Effect<WalletState> =>
Effect.gen(function* () {
const now = yield* Clock.currentTimeMillis;
return WalletState.Connecting({ startedAt: now });
});
const completeConnection = (address: string): Effect.Effect<WalletState> =>
Effect.gen(function* () {
const now = yield* Clock.currentTimeMillis;
return WalletState.Connected({
address,
connectedAt: now
});
});
const fail = (message: string): Effect.Effect<WalletState> =>
Effect.gen(function* () {
const now = yield* Clock.currentTimeMillis;
return WalletState.Error({
message,
occurredAt: now
});
});
// Pattern match for display
const displayState = (state: WalletState): string =>
WalletState.$match(state, {
Disconnected: () => 'Please connect your wallet',
Connecting: ({ startedAt }) =>
`Connecting... (started at ${startedAt})`,
Connected: ({ address, connectedAt }) =>
`Connected: ${address} (at ${connectedAt})`,
Error: ({ message, occurredAt }) =>
`Error: ${message} (at ${occurredAt})`
});
// Type-safe state queries using $is
const isConnected = WalletState.$is('Connected');
const canDisconnect = (state: WalletState): boolean =>
isConnected(state) || WalletState.$is('Error')(state);
// Filter connected states
const getConnectedStates = (
states: WalletState[]
): Array<Extract<WalletState, { _tag: 'Connected' }>> =>
states.filter(isConnected);
Quality Checklist
Before completing pattern matching implementation:
- Use
Data.TaggedEnumfor ADTs (not manual tagged unions) - Use
TaggedEnum.$matchfor exhaustive matching - Use
TaggedEnum.$isfor type guards (not._tag ===) - Use
Effect.matchinstead ofEffect.result+ if checks - Use
Option.matchinstead ofOption._tagchecks - Use
Match.typeTagsfor Schema union matching - Use
Match.fnwhen matching a selector while preserving multiple function arguments - All pattern matches are exhaustive (compiler-checked)
- Use
Clockservice instead ofDate.now()in matches - Use
Randomservice instead ofMath.random()in matches - Pattern matching is declarative (no imperative conditionals)
- Pipeline-friendly composition
- Type-safe refinement in each branch
Common Patterns Summary
ADT Definition
import { Data } from 'effect';
type State = Data.TaggedEnum<{
VariantA: { field: string };
VariantB: { other: number };
}>;
const State = Data.taggedEnum<State>();
Exhaustive Matching
import { Data } from 'effect';
type State = Data.TaggedEnum<{
VariantA: { field: string };
VariantB: { other: number };
}>;
const State = Data.taggedEnum<State>();
declare const state: State;
declare function handleA(field: string): void;
declare function handleB(other: number): void;
State.$match(state, {
VariantA: ({ field }) => handleA(field),
VariantB: ({ other }) => handleB(other)
});
Type Guards
import { Data } from 'effect';
type State = Data.TaggedEnum<{
VariantA: { field: string };
VariantB: { other: number };
}>;
const State = Data.taggedEnum<State>();
declare const state: State;
declare const items: State[];
if (State.$is('VariantA')(state)) {
// state is refined to VariantA
}
// In filters
items.filter(State.$is('VariantA'));
Effect Matching
import { Effect } from 'effect';
declare const effect: Effect.Effect<unknown, unknown, unknown>;
declare function handleError(error: unknown): unknown;
declare function handleSuccess(value: unknown): unknown;
effect.pipe(
Effect.match({
onFailure: (error) => handleError(error),
onSuccess: (value) => handleSuccess(value)
})
);
Option Matching
import { Option } from 'effect';
declare const option: Option.Option<string>;
declare const defaultValue: string;
declare function transform(value: string): string;
Option.match(option, {
onNone: () => defaultValue,
onSome: (value) => transform(value)
});
Your pattern matching implementations should be exhaustive, type-safe, declarative, and testable.