Skip to content
Effect Days 2026 Get your ticket
Docs menu / Modeling Errors with Effect

Modeling Errors with Effect

Thrown errors work, but they hide one thing: when you call a function, what can fail?

This tutorial shows how Effect makes each error part of a function’s return type, so TypeScript tracks it through every caller.

You use two functions throughout the tutorial:

FunctionDescription
authorizePaymentSimulates the payment provider: a sandbox token produces an authorization, a decline, or one of the errors below.
processPaymentCalls authorizePayment. This second function shows what happens to errors when one function calls another.

authorizePayment can fail three ways:

  • The card is expired.
  • The token is not valid.
  • The provider is unreachable.

Keep those three in mind. Each one becomes visible to TypeScript later.

Thrown errors are invisible

A typical TypeScript function signature says what it needs and what it returns. It says nothing about what the function can throw.

Here are common implementations of authorizePayment and processPayment without Effect:

// Input: sandbox tokens make results reproducible
type
type PaymentToken = "tok_authorized" | "tok_declined" | "tok_expiredcard" | "tok_invalid" | "tok_timeout"
PaymentToken
=
| "tok_authorized" // The provider authorizes the payment
| "tok_declined" // The provider declines the payment
| "tok_expiredcard" // The card is expired
| "tok_invalid" // The token is not valid
| "tok_timeout" // The provider is unreachable
// Output
interface
interface Authorization
Authorization
{
readonly
Authorization.id: string
id
: string
readonly
Authorization.status: "authorized" | "declined"
status
: "authorized" | "declined"
}
// Errors
class
class ExpiredCardError
ExpiredCardError
extends
var Error: ErrorConstructor
Error
{}
class
class InvalidTokenError
InvalidTokenError
extends
var Error: ErrorConstructor
Error
{}
class
class NetworkError
NetworkError
extends
var Error: ErrorConstructor
Error
{}
// Simulates the payment provider
const
const authorizePayment: (token: PaymentToken) => Promise<Authorization>
authorizePayment
= async (
token: PaymentToken
token
:
type PaymentToken = "tok_authorized" | "tok_declined" | "tok_expiredcard" | "tok_invalid" | "tok_timeout"
PaymentToken
,
):
interface Promise<T>
Promise
<
interface Authorization
Authorization
> => {
if (
token: PaymentToken
token
=== "tok_expiredcard") {
throw new
constructor ExpiredCardError(message?: string, options?: ErrorOptions): ExpiredCardError (+1 overload)
ExpiredCardError
()
}
if (
token: "tok_authorized" | "tok_declined" | "tok_invalid" | "tok_timeout"
token
=== "tok_invalid") {
throw new
constructor InvalidTokenError(message?: string, options?: ErrorOptions): InvalidTokenError (+1 overload)
InvalidTokenError
()
}
if (
token: "tok_authorized" | "tok_declined" | "tok_timeout"
token
=== "tok_timeout") {
throw new
constructor NetworkError(message?: string, options?: ErrorOptions): NetworkError (+1 overload)
NetworkError
()
}
if (
token: "tok_authorized" | "tok_declined"
token
=== "tok_declined") {
return {
Authorization.id: string
id
: "pauth_456",
Authorization.status: "authorized" | "declined"
status
: "declined" }
}
if (
token: "tok_authorized"
token
=== "tok_authorized") {
return {
Authorization.id: string
id
: "pauth_123",
Authorization.status: "authorized" | "declined"
status
: "authorized" }
}
throw new
constructor InvalidTokenError(message?: string, options?: ErrorOptions): InvalidTokenError (+1 overload)
InvalidTokenError
()
}
// Calls the payment provider from application code
const
const processPayment: (token: PaymentToken) => Promise<void>
processPayment
= async (
token: PaymentToken
token
:
type PaymentToken = "tok_authorized" | "tok_declined" | "tok_expiredcard" | "tok_invalid" | "tok_timeout"
PaymentToken
) => {
try {
const
const payment: Authorization
payment
= await
const authorizePayment: (token: PaymentToken) => Promise<Authorization>
authorizePayment
("tok_declined")
} catch (
function (local var) error: unknown
error
) {
// `error` is `unknown`. TypeScript does not know which errors
// `authorizePayment` can throw.
if (
function (local var) error: unknown
error
instanceof
class ExpiredCardError
ExpiredCardError
) {
// ...
}
if (
function (local var) error: unknown
error
instanceof
class InvalidTokenError
InvalidTokenError
) {
// ...
}
if (
function (local var) error: unknown
error
instanceof
class NetworkError
NetworkError
) {
// ...
}
throw
function (local var) error: unknown
error
}
}

authorizePayment can throw the three errors listed above, but its return type is only Promise<Authorization>. To discover those errors, a caller must read the implementation or rely on documentation that may fall out of date.

// ┌─ Needs `token`
// │ ┌─ Returns `Authorization`
// ▼ ▼
const authorizePayment: (token: PaymentToken) => Promise<Authorization>
// └─ How can it fail?

Because the signature does not name the errors, TypeScript cannot check how processPayment handles them. The missing type information causes four problems:

  • Blindness: in catch (error), TypeScript gives the caught value the type unknown, so it cannot tell which error authorizePayment threw.
  • Over-specification: processPayment can handle an error that authorizePayment never throws, and TypeScript still compiles it.
  • Under-specification: processPayment can omit a handler for an error that authorizePayment throws, and TypeScript still compiles it.
  • Drift: if authorizePayment starts throwing a new error, the handlers in processPayment fall out of sync without causing a compile error.

Make each error visible to TypeScript

A caller needs three pieces of information about a function:

  1. What the function needs.
  2. What it returns on success.
  3. How it can fail.

The signature of authorizePayment provides only the first two.

Imagine the signature named those errors with a union, so callers could see all three without reading the implementation:

// Not real TypeScript
const authorizePayment: (token: PaymentToken) => Promise<Authorization> throws
ExpiredCardError | InvalidTokenError | NetworkError

Unfortunately, TypeScript has no throws clause, so thrown errors cannot appear in the function’s type. One alternative is to treat an expected failure as a value and return either it or the successful value:

// `ok` tells the caller whether the outcome contains an authorization or an error.
type AuthorizationOutcome =
| { readonly ok: true; readonly authorization: Authorization }
| {
readonly ok: false
readonly error: ExpiredCardError | InvalidTokenError | NetworkError
}
// The Promise resolves to one of the two outcomes above.
const authorizePayment: (token: PaymentToken) => Promise<AuthorizationOutcome>

AuthorizationOutcome exposes both cases at the type level. At runtime, the caller checks ok. This becomes repetitive: every caller must inspect the result and return any error it cannot handle.

The Effect library removes this manual work by propagating unhandled failures. Its Effect<Success, Error> type records both possible outcomes:

// ┌─ What the function needs
// │ ┌─ What it returns on success
// │ │ ┌─ How it can fail
// ▼ ▼ ▼
const authorizePayment: (token: PaymentToken) => Effect<Authorization, ExpiredCardError | InvalidTokenError | NetworkError>

For now, focus on the return type. Authorization is the success value. The union contains the expected errors, which are failures a caller can handle. Effect puts them in the Error type parameter, and TypeScript tracks them through every caller.

Keep error types distinct

The Error type parameter in Effect<Success, Error> can list several expected errors as a union. This tells callers which failures they may need to handle. At runtime, the program fails with one of them, and the caller may respond differently to each error, such as retrying a network request or asking for a different card.

There is a problem, however, with the original error definitions:

class
class ExpiredCardError
ExpiredCardError
extends
var Error: ErrorConstructor
Error
{}
class
class InvalidTokenError
InvalidTokenError
extends
var Error: ErrorConstructor
Error
{}
class
class NetworkError
NetworkError
extends
var Error: ErrorConstructor
Error
{}

These classes are distinct at runtime, so instanceof works. At the type level, however, they have the same shape. TypeScript cannot distinguish them in an Effect’s Error union.

The Effect library solves this with Data.TaggedError. Give each error an identifier that is unique across your application:

import {
import Data
Data
} from "effect"
class
class ExpiredCardError
ExpiredCardError
extends
import Data
Data
.
const TaggedError: <"ExpiredCardError">(tag: "ExpiredCardError") => new <A>(args: VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => YieldableError & {
readonly _tag: "ExpiredCardError";
} & Readonly<A>
TaggedError
("ExpiredCardError") {}
class
class InvalidTokenError
InvalidTokenError
extends
import Data
Data
.
const TaggedError: <"InvalidTokenError">(tag: "InvalidTokenError") => new <A>(args: VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => YieldableError & {
readonly _tag: "InvalidTokenError";
} & Readonly<A>
TaggedError
("InvalidTokenError") {}
class
class NetworkError
NetworkError
extends
import Data
Data
.
const TaggedError: <"NetworkError">(tag: "NetworkError") => new <A>(args: VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => YieldableError & {
readonly _tag: "NetworkError";
} & Readonly<A>
TaggedError
("NetworkError") {}
const
const error: ExpiredCardError
error
= new
constructor ExpiredCardError<{}>(args: void): ExpiredCardError
ExpiredCardError
()
var console: Console
console
.
Console.log(...data: any[]): void
log
(
const error: ExpiredCardError
error
.
_tag: "ExpiredCardError"
_tag
)
// Output: "ExpiredCardError"

These identifiers keep the error types distinct in the union. Data.TaggedError stores each identifier in a _tag property, which Effect later uses to select the matching error handler.

Rewrite the payment flow with Effect

Now you build the program with Effect. An Effect is lazily executed: creating one doesn’t run it. It describes a program that can succeed or fail.

Here is the same two-step program, first with a Promise, then with an Effect:

Promise

const
const program: () => Promise<{
first: "authorized" | "declined";
second: "authorized" | "declined";
}>
program
= async () => {
const
const first: Authorization
first
= await
const authorizePayment: (token: PaymentToken) => Promise<Authorization>
authorizePayment
("tok_authorized")
const
const second: Authorization
second
= await
const authorizePayment: (token: PaymentToken) => Promise<Authorization>
authorizePayment
("tok_declined")
return {
first: "authorized" | "declined"
first
:
const first: Authorization
first
.
Authorization.status: "authorized" | "declined"
status
,
second: "authorized" | "declined"
second
:
const second: Authorization
second
.
Authorization.status: "authorized" | "declined"
status
}
}
await
const program: () => Promise<{
first: "authorized" | "declined";
second: "authorized" | "declined";
}>
program
() // => { first: "authorized", second: "declined" }

Effect

const
const program: Effect.Effect<{
first: "authorized" | "declined";
second: "authorized" | "declined";
}, never, never>
program
=
import Effect
Effect
.
const gen: <Effect.Effect<Authorization, never, never>, {
first: "authorized" | "declined";
second: "authorized" | "declined";
}>(f: () => Generator<Effect.Effect<Authorization, never, never>, {
first: "authorized" | "declined";
second: "authorized" | "declined";
}, never>) => Effect.Effect<{
first: "authorized" | "declined";
second: "authorized" | "declined";
}, never, never> (+1 overload)
gen
(function* () {
const
const first: Authorization
first
= yield*
const authorizePayment: (token: PaymentToken) => Effect.Effect<Authorization>
authorizePayment
("tok_authorized")
const
const second: Authorization
second
= yield*
const authorizePayment: (token: PaymentToken) => Effect.Effect<Authorization>
authorizePayment
("tok_declined")
return {
first: "authorized" | "declined"
first
:
const first: Authorization
first
.
Authorization.status: "authorized" | "declined"
status
,
second: "authorized" | "declined"
second
:
const second: Authorization
second
.
Authorization.status: "authorized" | "declined"
status
}
})
await
import Effect
Effect
.
const runPromise: <{
first: "authorized" | "declined";
second: "authorized" | "declined";
}, never>(effect: Effect.Effect<{
first: "authorized" | "declined";
second: "authorized" | "declined";
}, never, never>, options?: Effect.RunOptions | undefined) => Promise<{
first: "authorized" | "declined";
second: "authorized" | "declined";
}>
runPromise
(
const program: Effect.Effect<{
first: "authorized" | "declined";
second: "authorized" | "declined";
}, never, never>
program
) // => { first: "authorized", second: "declined" }

Same shape, three swaps:

  • async function becomes Effect.gen(function* ...)
  • await becomes yield*
  • await program() becomes await Effect.runPromise(program)

If a step returns an error, the rest do not run, the same way a thrown error stops an async function.

Now refactor authorizePayment with Effect.

return yield* returns the same way as a success, and the rest of the Effect stops. There is no throw and no outer catch. The error gets added the Error type parameter, where TypeScript tracks it:

const
const authorizePayment: (token: PaymentToken) => Effect.Effect<Authorization, ExpiredCardError | InvalidTokenError | NetworkError>
authorizePayment
= (
token: PaymentToken
token
:
type PaymentToken = "tok_authorized" | "tok_declined" | "tok_expiredcard" | "tok_invalid" | "tok_timeout"
PaymentToken
,
):
import Effect
Effect
.
interface Effect<out A, out E = never, out R = never>
Effect
<
interface Authorization
Authorization
,
class ExpiredCardError
ExpiredCardError
|
class InvalidTokenError
InvalidTokenError
|
class NetworkError
NetworkError
> =>
import Effect
Effect
.
const gen: <Effect.Effect<never, ExpiredCardError, never> | Effect.Effect<never, InvalidTokenError, never> | Effect.Effect<never, NetworkError, never>, {
id: string;
status: "declined";
} | {
id: string;
status: "authorized";
}>(f: () => Generator<Effect.Effect<never, ExpiredCardError, never> | Effect.Effect<never, InvalidTokenError, never> | Effect.Effect<never, NetworkError, never>, {
id: string;
status: "declined";
} | {
id: string;
status: "authorized";
}, never>) => Effect.Effect<...> (+1 overload)
gen
(function* () {
if (
token: PaymentToken
token
=== "tok_expiredcard") {
return yield* new
constructor ExpiredCardError<{}>(args: void): ExpiredCardError
ExpiredCardError
()
}
if (
token: "tok_authorized" | "tok_declined" | "tok_invalid" | "tok_timeout"
token
=== "tok_invalid") {
return yield* new
constructor InvalidTokenError<{}>(args: void): InvalidTokenError
InvalidTokenError
()
}
if (
token: "tok_authorized" | "tok_declined" | "tok_timeout"
token
=== "tok_timeout") {
return yield* new
constructor NetworkError<{}>(args: void): NetworkError
NetworkError
()
}
if (
token: "tok_authorized" | "tok_declined"
token
=== "tok_declined") {
return {
id: string
id
: "pauth_456",
status: "declined"
status
: "declined" }
}
return {
id: string
id
: "pauth_123",
status: "authorized"
status
: "authorized" }
})

Refactor processPayment with Effect. Now calls authorizePayment and returns its result. It inherits the three errors, so it sees them:

// ┌─ (token: PaymentToken) => Effect.Effect<Authorization, ExpiredCardError | InvalidTokenError | NetworkError>
// ▼
const
const processPayment: (token: PaymentToken) => Effect.Effect<Authorization, ExpiredCardError | InvalidTokenError | NetworkError, never>
processPayment
= (
token: PaymentToken
token
:
type PaymentToken = "tok_authorized" | "tok_declined" | "tok_expiredcard" | "tok_invalid" | "tok_timeout"
PaymentToken
) =>
import Effect
Effect
.
const gen: <Effect.Effect<Authorization, ExpiredCardError | InvalidTokenError | NetworkError, never>, Authorization>(f: () => Generator<Effect.Effect<Authorization, ExpiredCardError | InvalidTokenError | NetworkError, never>, Authorization, never>) => Effect.Effect<Authorization, ExpiredCardError | InvalidTokenError | NetworkError, never> (+1 overload)
gen
(function* () {
const
const authorization: Authorization
authorization
= yield*
const authorizePayment: (token: PaymentToken) => Effect.Effect<Authorization, ExpiredCardError | InvalidTokenError | NetworkError>
authorizePayment
(
token: PaymentToken
token
)
return
const authorization: Authorization
authorization
})
await
import Effect
Effect
.
const runPromise: <Authorization, ExpiredCardError | InvalidTokenError | NetworkError>(effect: Effect.Effect<Authorization, ExpiredCardError | InvalidTokenError | NetworkError, never>, options?: Effect.RunOptions | undefined) => Promise<Authorization>
runPromise
(
const processPayment: (token: PaymentToken) => Effect.Effect<Authorization, ExpiredCardError | InvalidTokenError | NetworkError, never>
processPayment
("tok_authorized")) // => { id: "pauth_123", status: "authorized" }

TypeScript inferred the Error type parameter as the union of the three errors you return.

Handle the expected errors you modeled

The signature of authorizePayment lists the expected errors. The caller uses the list to handle them.

Effect.catchTag takes the _tag of one error and a handler. The handler is an Effect that receives the typed error as a parameter, not unknown.

The example uses method .pipe (available to every Effect), to apply the handler to the Effect, like method chaining:

const
const outcome: Effect.Effect<string | Authorization, InvalidTokenError | NetworkError, never>
outcome
=
const processPayment: (token: PaymentToken) => Effect.Effect<Authorization, ExpiredCardError | InvalidTokenError | NetworkError>
processPayment
("tok_expiredcard").
Pipeable.pipe<Effect.Effect<Authorization, ExpiredCardError | InvalidTokenError | NetworkError, never>, Effect.Effect<string | Authorization, InvalidTokenError | NetworkError, never>>(this: Effect.Effect<...>, ab: (_: Effect.Effect<Authorization, ExpiredCardError | InvalidTokenError | NetworkError, never>) => Effect.Effect<string | Authorization, InvalidTokenError | NetworkError, never>): Effect.Effect<...> (+21 overloads)
pipe
(
import Effect
Effect
.
const catchTag: <"ExpiredCardError", ExpiredCardError | InvalidTokenError | NetworkError, string, never, never, unassigned, never, never>(k: "ExpiredCardError", f: (e: ExpiredCardError) => Effect.Effect<string, never, never>, orElse?: ((e: InvalidTokenError | NetworkError) => Effect.Effect<unassigned, never, never>) | undefined) => <A, R>(self: Effect.Effect<A, ExpiredCardError | InvalidTokenError | NetworkError, R>) => Effect.Effect<...> (+1 overload)
catchTag
("ExpiredCardError", (
error: ExpiredCardError
error
) =>
// └─ `ExpiredCardError`
import Effect
Effect
.
const succeed: <string>(value: string) => Effect.Effect<string, never, never>
succeed
("Card expired, ask for another"),
),
)
await
import Effect
Effect
.
const runPromise: <string | Authorization, InvalidTokenError | NetworkError>(effect: Effect.Effect<string | Authorization, InvalidTokenError | NetworkError, never>, options?: Effect.RunOptions | undefined) => Promise<string | Authorization>
runPromise
(
const outcome: Effect.Effect<string | Authorization, InvalidTokenError | NetworkError, never>
outcome
) // => "Card expired, ask for another"

The handled error leaves the Error type parameter. The others stay:

// ┌─ `ExpiredCardError` is removed
// ▼
Effect.Effect<string | Authorization, InvalidTokenError | NetworkError>

TypeScript now has another job: it won’t compile a handler for a _tag that isn’t in the union.

Effect.catchTags (notice the s at the end) handles several errors at once with one table: an object keyed by _tag.

For each _tag, its handler receives the typed error:

const
const outcome: Effect.Effect<string | Authorization, InvalidTokenError, never>
outcome
=
const processPayment: (token: PaymentToken) => Effect.Effect<Authorization, ExpiredCardError | InvalidTokenError | NetworkError>
processPayment
("tok_timeout").
Pipeable.pipe<Effect.Effect<Authorization, ExpiredCardError | InvalidTokenError | NetworkError, never>, Effect.Effect<string | Authorization, InvalidTokenError, never>>(this: Effect.Effect<...>, ab: (_: Effect.Effect<Authorization, ExpiredCardError | InvalidTokenError | NetworkError, never>) => Effect.Effect<string | Authorization, InvalidTokenError, never>): Effect.Effect<...> (+21 overloads)
pipe
(
import Effect
Effect
.
const catchTags: <ExpiredCardError | InvalidTokenError | NetworkError, {
ExpiredCardError: (error: ExpiredCardError) => Effect.Effect<string, never, never>;
NetworkError: (error: NetworkError) => Effect.Effect<string, never, never>;
}, unassigned, never, never>(cases: {
ExpiredCardError: (error: ExpiredCardError) => Effect.Effect<string, never, never>;
NetworkError: (error: NetworkError) => Effect.Effect<string, never, never>;
}, orElse?: ((e: InvalidTokenError) => Effect.Effect<...>) | undefined) => <A, R>(self: Effect.Effect<...>) => Effect.Effect<...> (+1 overload)
catchTags
({
// ┌─ `ExpiredCardError`
// ▼
type ExpiredCardError: (error: ExpiredCardError) => Effect.Effect<string, never, never>
ExpiredCardError
: (
error: ExpiredCardError
error
) =>
import Effect
Effect
.
const succeed: <string>(value: string) => Effect.Effect<string, never, never>
succeed
("Card expired, ask for another"),
// ┌─ `NetworkError`
// ▼
type NetworkError: (error: NetworkError) => Effect.Effect<string, never, never>
NetworkError
: (
error: NetworkError
error
) =>
import Effect
Effect
.
const succeed: <string>(value: string) => Effect.Effect<string, never, never>
succeed
("Provider unreachable, try again"),
}),
)
await
import Effect
Effect
.
const runPromise: <string | Authorization, InvalidTokenError>(effect: Effect.Effect<string | Authorization, InvalidTokenError, never>, options?: Effect.RunOptions | undefined) => Promise<string | Authorization>
runPromise
(
const outcome: Effect.Effect<string | Authorization, InvalidTokenError, never>
outcome
) // => "Provider unreachable, try again"

Two more rules TypeScript enforces:

  1. Every key must be a _tag from the union. A typo does not compile.
  2. The result keeps what the table misses: Effect.Effect<Authorization, InvalidTokenError>.

When all the errors are handled, the Error type parameter becomes never:

// ┌─ (token: PaymentToken) => Effect.Effect<string | Authorization, never>
// ▼
const
const handle: (token: PaymentToken) => Effect.Effect<string | Authorization, never, never>
handle
= (
token: PaymentToken
token
:
type PaymentToken = "tok_authorized" | "tok_declined" | "tok_expiredcard" | "tok_invalid" | "tok_timeout"
PaymentToken
) =>
const processPayment: (token: PaymentToken) => Effect.Effect<Authorization, ExpiredCardError | InvalidTokenError | NetworkError>
processPayment
(
token: PaymentToken
token
).
Pipeable.pipe<Effect.Effect<Authorization, ExpiredCardError | InvalidTokenError | NetworkError, never>, Effect.Effect<string | Authorization, never, never>>(this: Effect.Effect<Authorization, ExpiredCardError | ... 1 more ... | NetworkError, never>, ab: (_: Effect.Effect<Authorization, ExpiredCardError | InvalidTokenError | NetworkError, never>) => Effect.Effect<string | Authorization, never, never>): Effect.Effect<...> (+21 overloads)
pipe
(
import Effect
Effect
.
const catchTags: <ExpiredCardError | InvalidTokenError | NetworkError, {
ExpiredCardError: () => Effect.Effect<string, never, never>;
InvalidTokenError: () => Effect.Effect<string, never, never>;
NetworkError: () => Effect.Effect<string, never, never>;
}, unassigned, never, never>(cases: {
ExpiredCardError: () => Effect.Effect<string, never, never>;
InvalidTokenError: () => Effect.Effect<string, never, never>;
NetworkError: () => Effect.Effect<string, never, never>;
}, orElse?: ((e: never) => Effect.Effect<...>) | undefined) => <A, R>(self: Effect.Effect<...>) => Effect.Effect<...> (+1 overload)
catchTags
({
type ExpiredCardError: () => Effect.Effect<string, never, never>
ExpiredCardError
: () =>
import Effect
Effect
.
const succeed: <string>(value: string) => Effect.Effect<string, never, never>
succeed
("..."),
type InvalidTokenError: () => Effect.Effect<string, never, never>
InvalidTokenError
: () =>
import Effect
Effect
.
const succeed: <string>(value: string) => Effect.Effect<string, never, never>
succeed
("..."),
type NetworkError: () => Effect.Effect<string, never, never>
NetworkError
: () =>
import Effect
Effect
.
const succeed: <string>(value: string) => Effect.Effect<string, never, never>
succeed
("..."),
}),
)

Modeling unexpected errors: defects

Every error so far is one a caller can handle. These are expected errors. But never does not mean the code cannot fail.

One condition must always hold: an authorization is captured once, never twice. This condition is an invariant. If it breaks, the payment step charges the customer twice.

When an invariant breaks, no handler can respond. The safe action is to stop and report.

Effect calls this failure an unexpected error, or a defect: an impossible state, a broken invariant, or a bug in third-party code.

A defect is not tracked in the Error type parameter. The runtime records it in a Cause.

The capture step enforces this invariant. The ledger fixture starts with the payment already captured. So this run is the retry, and the invariant breaks.

No caller can undo a double capture. A typed error would promise a handler that cannot exist. The step dies instead. Effect.die sends the error to the Cause, and the Error type parameter stays empty.

import {
import Effect
Effect
,
import Data
Data
} from "effect"
10 collapsed lines
interface
interface Authorization
Authorization
{
readonly
Authorization.id: string
id
: string
readonly
Authorization.status: "authorized" | "declined"
status
: "authorized" | "declined"
}
class
class ExpiredCardError
ExpiredCardError
extends
import Data
Data
.
const TaggedError: <"ExpiredCardError">(tag: "ExpiredCardError") => new <A>(args: VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => YieldableError & {
readonly _tag: "ExpiredCardError";
} & Readonly<A>
TaggedError
("ExpiredCardError") {}
class
class InvalidTokenError
InvalidTokenError
extends
import Data
Data
.
const TaggedError: <"InvalidTokenError">(tag: "InvalidTokenError") => new <A>(args: VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => YieldableError & {
readonly _tag: "InvalidTokenError";
} & Readonly<A>
TaggedError
("InvalidTokenError") {}
class
class NetworkError
NetworkError
extends
import Data
Data
.
const TaggedError: <"NetworkError">(tag: "NetworkError") => new <A>(args: VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => YieldableError & {
readonly _tag: "NetworkError";
} & Readonly<A>
TaggedError
("NetworkError") {}
class
class DoubleCaptureError
DoubleCaptureError
extends
import Data
Data
.
const TaggedError: <"DoubleCaptureError">(tag: "DoubleCaptureError") => new <A>(args: VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => YieldableError & {
readonly _tag: "DoubleCaptureError";
} & Readonly<A>
TaggedError
("DoubleCaptureError")<{
readonly
id: string
id
: string
}> {}
28 collapsed lines
type
type PaymentToken = "tok_authorized" | "tok_declined" | "tok_expiredcard" | "tok_invalid" | "tok_timeout"
PaymentToken
=
| "tok_authorized"
| "tok_declined"
| "tok_expiredcard"
| "tok_invalid"
| "tok_timeout"
const
const processPayment: (token: PaymentToken) => Effect.Effect<Authorization, ExpiredCardError | InvalidTokenError | NetworkError>
processPayment
= (
token: PaymentToken
token
:
type PaymentToken = "tok_authorized" | "tok_declined" | "tok_expiredcard" | "tok_invalid" | "tok_timeout"
PaymentToken
,
):
import Effect
Effect
.
interface Effect<out A, out E = never, out R = never>
Effect
<
interface Authorization
Authorization
,
class ExpiredCardError
ExpiredCardError
|
class InvalidTokenError
InvalidTokenError
|
class NetworkError
NetworkError
> =>
import Effect
Effect
.
const gen: <Effect.Effect<never, ExpiredCardError, never> | Effect.Effect<never, InvalidTokenError, never> | Effect.Effect<never, NetworkError, never>, {
id: string;
status: "authorized";
}>(f: () => Generator<Effect.Effect<never, ExpiredCardError, never> | Effect.Effect<never, InvalidTokenError, never> | Effect.Effect<never, NetworkError, never>, {
id: string;
status: "authorized";
}, never>) => Effect.Effect<...> (+1 overload)
gen
(function* () {
if (
token: PaymentToken
token
=== "tok_expiredcard") {
return yield* new
constructor ExpiredCardError<{}>(args: void): ExpiredCardError
ExpiredCardError
()
}
if (
token: "tok_authorized" | "tok_declined" | "tok_invalid" | "tok_timeout"
token
=== "tok_invalid") {
return yield* new
constructor InvalidTokenError<{}>(args: void): InvalidTokenError
InvalidTokenError
()
}
if (
token: "tok_authorized" | "tok_declined" | "tok_timeout"
token
=== "tok_timeout") {
return yield* new
constructor NetworkError<{}>(args: void): NetworkError
NetworkError
()
}
return {
id: string
id
: "pauth_123",
status: "authorized"
status
: "authorized" }
})
// Tutorial fixture: an in-memory ledger, so captures are reproducible.
// The first attempt already captured the payment. This run is the retry.
const
const captured: Set<string>
captured
= new
var Set: SetConstructor
new <string>(iterable?: Iterable<string> | null | undefined) => Set<string> (+1 overload)
Set
(["pauth_123"])
// ┌─ Effect.Effect<Authorization>: no `Error` type parameter
// ▼
const
const capturePayment: (authorization: Authorization) => Effect.Effect<Authorization>
capturePayment
= (
authorization: Authorization
authorization
:
interface Authorization
Authorization
,
):
import Effect
Effect
.
interface Effect<out A, out E = never, out R = never>
Effect
<
interface Authorization
Authorization
> =>
import Effect
Effect
.
const gen: <Effect.Effect<never, never, never>, Authorization>(f: () => Generator<Effect.Effect<never, never, never>, Authorization, never>) => Effect.Effect<Authorization, never, never> (+1 overload)
gen
(function* () {
if (
const captured: Set<string>
captured
.
Set<string>.has(value: string): boolean
has
(
authorization: Authorization
authorization
.
Authorization.id: string
id
)) {
// Invariant broken: the step dies instead of failing with a
// typed error no caller could handle.
return yield*
import Effect
Effect
.
const die: (defect: unknown) => Effect.Effect<never>
die
(
new
constructor DoubleCaptureError<{
readonly id: string;
}>(args: {
readonly id: string;
}): DoubleCaptureError
DoubleCaptureError
({
id: string
id
:
authorization: Authorization
authorization
.
Authorization.id: string
id
,
}),
)
}
const captured: Set<string>
captured
.
Set<string>.add(value: string): Set<string>
add
(
authorization: Authorization
authorization
.
Authorization.id: string
id
)
return
authorization: Authorization
authorization
})
const
const program: Effect.Effect<string | Authorization, ExpiredCardError | InvalidTokenError | NetworkError, never>
program
=
import Effect
Effect
.
const gen: <Effect.Effect<Authorization, ExpiredCardError | InvalidTokenError | NetworkError, never> | Effect.Effect<string | Authorization, never, never>, string | Authorization>(f: () => Generator<Effect.Effect<Authorization, ExpiredCardError | InvalidTokenError | NetworkError, never> | Effect.Effect<string | Authorization, never, never>, string | Authorization, never>) => Effect.Effect<...> (+1 overload)
gen
(function* () {
const
const payment: Authorization
payment
= yield*
const processPayment: (token: PaymentToken) => Effect.Effect<Authorization, ExpiredCardError | InvalidTokenError | NetworkError>
processPayment
("tok_authorized")
return yield*
const capturePayment: (authorization: Authorization) => Effect.Effect<Authorization>
capturePayment
(
const payment: Authorization
payment
).
Pipeable.pipe<Effect.Effect<Authorization, never, never>, Effect.Effect<string | Authorization, never, never>>(this: Effect.Effect<Authorization, never, never>, ab: (_: Effect.Effect<Authorization, never, never>) => Effect.Effect<string | Authorization, never, never>): Effect.Effect<string | Authorization, never, never> (+21 overloads)
pipe
(
import Effect
Effect
.
const catchDefect: <string, never, never>(f: (defect: unknown) => Effect.Effect<string, never, never>) => <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<string | A, E, R> (+1 overload)
catchDefect
((
defect: unknown
defect
) =>
defect: unknown
defect
instanceof
class DoubleCaptureError
DoubleCaptureError
?
import Effect
Effect
.
const succeed: <string>(value: string) => Effect.Effect<string, never, never>
succeed
(`terminated: ${
defect: DoubleCaptureError
defect
.
id: string
id
} already captured`)
:
import Effect
Effect
.
const die: (defect: unknown) => Effect.Effect<never>
die
(
defect: unknown
defect
),
),
)
})
await
import Effect
Effect
.
const runPromise: <string | Authorization, ExpiredCardError | InvalidTokenError | NetworkError>(effect: Effect.Effect<string | Authorization, ExpiredCardError | InvalidTokenError | NetworkError, never>, options?: Effect.RunOptions | undefined) => Promise<string | Authorization>
runPromise
(
const program: Effect.Effect<string | Authorization, ExpiredCardError | InvalidTokenError | NetworkError, never>
program
) // => "terminated: pauth_123 already captured"

The signature promises Effect.Effect<Authorization> with no Error type parameter. Yet the double capture fails the run. The failure never entered the type. It became a defect, and Effect.catchDefect caught it. Without that handler, Effect.runPromise rejects. Unknown defects stay fatal, so the handler re-raises them with Effect.die.

Remove errors no caller can handle from the type

Effect.orDie is a modeling decision. No caller can handle these errors, so stop tracking them. Every typed error becomes a defect, and the Error type parameter disappears.

Apply it at the boundary between your Effect code and its callers. Inside, the code still fails with typed errors. The signature the caller sees has none left.

// ┌─ Effect.Effect<Authorization>: `Error` type parameter is gone
// ▼
const
const reconcile: Effect.Effect<Authorization, never, never>
reconcile
=
const processPayment: (token: PaymentToken) => Effect.Effect<Authorization, ExpiredCardError | InvalidTokenError | NetworkError>
processPayment
("tok_expiredcard").
Pipeable.pipe<Effect.Effect<Authorization, ExpiredCardError | InvalidTokenError | NetworkError, never>, Effect.Effect<Authorization, never, never>>(this: Effect.Effect<Authorization, ExpiredCardError | ... 1 more ... | NetworkError, never>, ab: (_: Effect.Effect<Authorization, ExpiredCardError | InvalidTokenError | NetworkError, never>) => Effect.Effect<Authorization, never, never>): Effect.Effect<...> (+21 overloads)
pipe
(
import Effect
Effect
.
const orDie: <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, never, R>
orDie
)
try {
await
import Effect
Effect
.
const runPromise: <Authorization, never>(effect: Effect.Effect<Authorization, never, never>, options?: Effect.RunOptions | undefined) => Promise<Authorization>
runPromise
(
const reconcile: Effect.Effect<Authorization, never, never>
reconcile
)
} catch (
var defect: unknown
defect
) {
var defect: unknown
defect
instanceof
class ExpiredCardError
ExpiredCardError
&&
var defect: ExpiredCardError
defect
.
_tag: "ExpiredCardError"
_tag
// => "ExpiredCardError"
}

A nightly reconciliation job is one such boundary. When the card is expired, the job crashes and alerts its owner. The failure crossed the boundary as a defect. So Effect.runPromise rejected with the ExpiredCardError itself.

Handle defects at the application boundary

A defect is a bug. A handler cannot fix it. But the boundary decides what happens next, once, in one place: the job runner, the HTTP handler, the batch loop. For each defect, choose:

  1. Report it, and let the program stop.
  2. Or catch it, report it, and let the program continue.

If a caller can respond to the failure, it is not a defect. Model it as a typed error instead.

Inside the program, defects stay fatal. Only the boundary decides.

Effect.catchDefect handles defects only. The next example shows that typed errors pass through unchanged.

const
const outcome: Effect.Effect<string | Authorization, ExpiredCardError | InvalidTokenError | NetworkError, never>
outcome
=
const processPayment: (token: PaymentToken) => Effect.Effect<Authorization, ExpiredCardError | InvalidTokenError | NetworkError>
processPayment
("tok_expiredcard").
Pipeable.pipe<Effect.Effect<Authorization, ExpiredCardError | InvalidTokenError | NetworkError, never>, Effect.Effect<string | Authorization, ExpiredCardError | InvalidTokenError | NetworkError, never>>(this: Effect.Effect<...>, ab: (_: Effect.Effect<Authorization, ExpiredCardError | InvalidTokenError | NetworkError, never>) => Effect.Effect<...>): Effect.Effect<...> (+21 overloads)
pipe
(
import Effect
Effect
.
const catchDefect: <string, never, never>(f: (defect: unknown) => Effect.Effect<string, never, never>) => <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<string | A, E, R> (+1 overload)
catchDefect
((
defect: unknown
defect
) =>
defect: unknown
defect
instanceof
class DoubleCaptureError
DoubleCaptureError
?
import Effect
Effect
.
const succeed: <string>(value: string) => Effect.Effect<string, never, never>
succeed
(`reported: ${
defect: DoubleCaptureError
defect
.
id: string
id
} already captured`)
:
import Effect
Effect
.
const die: (defect: unknown) => Effect.Effect<never>
die
(
defect: unknown
defect
),
),
)
try {
await
import Effect
Effect
.
const runPromise: <string | Authorization, ExpiredCardError | InvalidTokenError | NetworkError>(effect: Effect.Effect<string | Authorization, ExpiredCardError | InvalidTokenError | NetworkError, never>, options?: Effect.RunOptions | undefined) => Promise<string | Authorization>
runPromise
(
const outcome: Effect.Effect<string | Authorization, ExpiredCardError | InvalidTokenError | NetworkError, never>
outcome
)
} catch (
var failure: unknown
failure
) {
var failure: unknown
failure
instanceof
class ExpiredCardError
ExpiredCardError
&&
var failure: ExpiredCardError
failure
.
_tag: "ExpiredCardError"
_tag
// => "ExpiredCardError"
}

The catchDefect handler never ran. The result is catch (failure), not catch (defect). The typed error was never a defect. It reached runPromise unchanged, exactly as its type said, with all three members still in the Error type parameter.

A rule of thumb for the whole section:

  • Prefer the typed operators for the errors you modeled: Effect.catchTag and Effect.catchTags.
  • Use Effect.catchDefect only where handling the unexpected is intentional and safe.

Let’s review what we built

You can now do five things. Each one maps to an operator you used.

  • Read an Effect signature as a contract. The Success type parameter names the result. The Error type parameter names every error a caller can handle.
  • Make each error visible to TypeScript with Data.TaggedError. TypeScript tracks it instead of losing it as unknown in a catch.
  • Return errors instead of throwing them. Inside Effect.gen, fail with return yield* new XError(). The Error type parameter tracks each one.
  • Handle one _tag with Effect.catchTag, and many with Effect.catchTags. Each handled tag leaves the Error union. An empty union is never.
  • Keep defects apart from typed errors. Effect.die records a defect in the Cause. Effect.catchDefect handles defects at the boundary. Effect.orDie converts tracked errors you no longer handle.

Further reading

Each guide below builds on one part of this tutorial.