| name | effect-pattern-matching |
| description | Master Effect pattern matching using Data.TaggedEnum, $match, $is, Match.typeTags, and Effect.match. Avoid manual _tag checks and Effect.result patterns. Use this skill when working with discriminated unions, ADTs, or conditional logic based on tagged types. |
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 ~/.cache/effect-v4/.
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
Pattern 1: Data.TaggedEnum for ADTs
Use Data.TaggedEnum instead of manual tagged unions.
The Problem: Manual Tagged Unions
type WalletState =
| { readonly _tag: 'Disconnected' }
| { readonly _tag: 'Connecting' }
| { readonly _tag: 'Connected'; readonly address: string }
| { readonly _tag: 'Error'; readonly message: string };
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 });
The Solution: Data.TaggedEnum
import { Data } from 'effect';
type WalletState = Data.TaggedEnum<{
Disconnected: {};
Connecting: {};
Connected: { readonly address: string };
Error: { readonly message: string };
}>;
const WalletState = Data.taggedEnum<WalletState>();
const state = WalletState.Connected({ address: '0x123' });
const display = WalletState.$match(state, {
Disconnected: () => 'Please connect wallet',
Connecting: () => 'Connecting...',
Connected: ({ address }) => `Connected: ${address}`,
Error: ({ message }) => `Error: ${message}`
});
if (WalletState.$is('Connected')(state)) {
console.log(state.address);
}
Caveat: Data.$is(tag) / TaggedEnum.$is(tag) only checks the _tag field, not the full structure. Use it for values produced by your constructors; validate untrusted input with Schema before 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
import { Effect, Result, Schema } from 'effect';
declare const User: { name: string; id: string };
type User = typeof User;
class NotFound extends Schema.TaggedErrorClass<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'));
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
import { Effect, Schema } from 'effect';
declare const User: { name: string; id: string };
type User = typeof User;
class NotFound extends Schema.TaggedErrorClass<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;
Effect.match(effect, {
onFailure: (error) => handleError(error),
onSuccess: (value) => handleSuccess(value)
});
Effect.matchEffect(effect, {
onFailure: (error) => Effect.logError(error).pipe(Effect.as(null)),
onSuccess: (value) => Effect.succeed(value)
});
Effect.matchCause(effect, {
onFailure: (cause) => handleCause(cause),
onSuccess: (value) => value
});
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
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';
}
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
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'
});
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
import { Data } from 'effect';
type Status = Data.TaggedEnum<{
Active: {};
Expired: {};
}>;
const Status = Data.taggedEnum<Status>();
const status = Status.Active();
if (status._tag === 'Active') {
console.log('Active!');
}
const items: Status[] = [Status.Active(), Status.Expired()];
const activeItems = items.filter((item) => item._tag === 'Active');
The Solution: $is Type Guards
import { Data, Array, pipe } from 'effect';
type Status = Data.TaggedEnum<{
Active: {};
Expired: {};
}>;
const Status = Data.taggedEnum<Status>();
const status = Status.Active();
if (Status.$is('Active')(status)) {
console.log('Active!');
}
const items: Status[] = [Status.Active(), Status.Expired()];
const activeItems = items.filter(Status.$is('Active'));
const hasActive = pipe(items, Array.some(Status.$is('Active')));
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 = (state: LoadState): string[] =>
pipe(
state,
LoadState.$is('Ready'),
(ready) => (ready ? ready.data : [])
);
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
import { Option } from 'effect';
type User = { name: string; id: string };
const maybeUser: Option.Option<User> = Option.some({
name: 'Alice',
id: '123'
});
if (maybeUser._tag === 'Some') {
console.log(maybeUser.value.name);
} else {
console.log('No user');
}
The Solution: 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
});
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;
Option.match(option, {
onNone: () => defaultValue,
onSome: (value) => transform(value)
});
Option.getOrElse(option, () => defaultValue);
pipe(
option,
Option.map(transform),
Option.getOrElse(() => defaultValue)
);
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';
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>;
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);
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;
const match = Match.typeTags<UnionType>();
const result = match({
VariantA: (data) => handleA(data),
VariantB: (data) => handleB(data)
})(value);
const matcher = match({
VariantA: (data) => handleA(data),
VariantB: (data) => handleB(data)
});
const result2 = matcher(value);
Pattern 7: 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} />
});
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
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
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}`
});
});
const testProgram = processState(State.Active()).pipe(
Effect.provide(TestClock.layer())
);
Random Values in Pattern Matching
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')
});
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';
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>();
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
});
});
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})`
});
const isConnected = WalletState.$is('Connected');
const canDisconnect = (state: WalletState): boolean =>
isConnected(state) || WalletState.$is('Error')(state);
const getConnectedStates = (
states: WalletState[]
): Array<Extract<WalletState, { _tag: 'Connected' }>> =>
states.filter(isConnected);
Quality Checklist
Before completing pattern matching implementation:
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)) {
}
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.