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:
| Function | Description |
|---|---|
authorizePayment | Simulates the payment provider: a sandbox token produces an authorization, a decline, or one of the errors below. |
processPayment | Calls 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 reproducibletype 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
// Outputinterface interface Authorization
Authorization { readonly Authorization.id: string
id: string readonly Authorization.status: "authorized" | "declined"
status: "authorized" | "declined"}
// Errorsclass 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 providerconst 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 codeconst 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 typeunknown, so it cannot tell which errorauthorizePaymentthrew. - Over-specification:
processPaymentcan handle an error thatauthorizePaymentnever throws, and TypeScript still compiles it. - Under-specification:
processPaymentcan omit a handler for an error thatauthorizePaymentthrows, and TypeScript still compiles it. - Drift: if
authorizePaymentstarts throwing a new error, the handlers inprocessPaymentfall out of sync without causing a compile error.
Make each error visible to TypeScript
A caller needs three pieces of information about a function:
- What the function needs.
- What it returns on success.
- 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 TypeScriptconst authorizePayment: (token: PaymentToken) => Promise<Authorization> throws ExpiredCardError | InvalidTokenError | NetworkErrorUnfortunately, 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 functionbecomesEffect.gen(function* ...)awaitbecomesyield*await program()becomesawait 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:
- Every key must be a
_tagfrom the union. A typo does not compile. - 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: SetConstructornew <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:
- Report it, and let the program stop.
- 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.catchTagandEffect.catchTags. - Use
Effect.catchDefectonly 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
Effectsignature as a contract. TheSuccesstype parameter names the result. TheErrortype parameter names every error a caller can handle. - Make each error visible to TypeScript with
Data.TaggedError. TypeScript tracks it instead of losing it asunknownin acatch. - Return errors instead of throwing them. Inside
Effect.gen, fail withreturn yield* new XError(). TheErrortype parameter tracks each one. - Handle one
_tagwithEffect.catchTag, and many withEffect.catchTags. Each handled tag leaves theErrorunion. An empty union isnever. - Keep defects apart from typed errors.
Effect.dierecords a defect in theCause.Effect.catchDefecthandles defects at the boundary.Effect.orDieconverts tracked errors you no longer handle.
Further reading
Each guide below builds on one part of this tutorial.
- Expected Errors, to answer an expired card or a timeout with a fallback value.
- Unexpected Errors, to report a double capture or an impossible status at the boundary.
- Yieldable Errors, to add a new tagged error to the signature.