Error Management #
Expected failures versus defects, tagged error classes, the catch family, and reading an Exit.
The problem. In plain TypeScript, every error uses the same mechanism: throw. Look at this checkout:
async function checkout(cart: Cart): Promise<Receipt> {
const stock = await reserve(cart) // may throw OutOfStock
const charge = await pay(cart.total) // may throw CardDeclined... or a TypeError from a typo
return receipt(stock, charge)
}
try {
await checkout(cart)
} catch (e) {
// e is unknown. Out of stock? Card declined? A bug? All three land here.
showBanner("Something went wrong")
}The signature says Promise<Receipt>. It does not say which errors can come out. The catch block gets unknown, so you write instanceof checks, and you hope that you remembered every case. Also, the catch block that you wrote for "card declined" also catches a real bug. The bug is now hidden behind a friendly banner.
The shift
Today you think of an error as a value that escapes. It exits the function and goes up the stack until a catch block stops it. In Effect, an error is a value that the program returns, in the same way that it returns a success. The error is in the type:
const checkout: (cart: Cart) => Effect<Receipt, OutOfStock | CardDeclined>Catch OutOfStock, and the type becomes Effect<Receipt, CardDeclined>. Catch CardDeclined too, and the type becomes Effect<Receipt, never>. The compiler now knows that no error is left. If you do not catch one error, the type keeps it. A function that declares never then does not compile. The error type becomes smaller as you catch errors, and you can read what is left at any point.
Effect also keeps 2 kinds of problem apart. A failure is expected and typed (Effect.fail). A defect is a bug: a thrown exception, or Effect.die. A defect is unexpected and not typed. The normal catch functions see only failures, so a bug cannot hide behind a fallback. An interrupt is the third kind: code outside the effect stopped it.
Plain TS try/catch |
Effect | |
|---|---|---|
| Where the error type is | Nowhere, catch (e) gets unknown |
In the second type parameter, E |
| Expected error vs bug | Same throw, same catch |
Effect.fail (typed) vs Effect.die (defect) |
| Catch one kind only | instanceof check, then throw the rest again |
catchTag("X", ...), the rest stays in E |
| A case is not caught | Compiles, then crashes in production | Compiles only if the type of the caller permits the case |
| See what happened | Lost after the catch |
Exit and Cause are plain values |
In this section you define errors, recover from them by tag or by condition, process defects, and read the full outcome. Retries and schedules come in a later section.
Learn #
Lesson 1. Two kinds of failure, and a third reason #
Plain TypeScript makes this bug easy. A function throws NotFound when a user does not exist. The caller wraps the function in try/catch and shows "user not found". One day, a typo inside the function throws a TypeError. The same catch block runs, the same message shows, and the bug goes to production.
Effect keeps the 2 kinds apart from the start:
Effect.fail(e)is an expected failure. It goes into theEtype parameter.Effect.die(x)is a defect, a bug. An exception thrown insideEffect.syncis also a defect. Defects are not in the type.Estaysnever.- An interrupt is the third reason: code outside the effect stopped the fiber that runs it. A fiber is the unit of execution that runs an effect.
When an effect does not succeed, its Exit holds a Cause. cause.reasons is an array of reasons. Each reason has a tag: Fail, Die, or Interrupt. The program below shows all 4 ways in which an effect does not succeed, and how each one looks.
import { Effect, Exit } from "effect"
const cases = {
fail: Effect.fail("user not found"), // Effect<never, string>
thrown: Effect.sync(() => { throw new TypeError("x is undefined") }), // Effect<never, never>: a bug
die: Effect.die("unreachable state"), // Effect<never, never>: a bug on purpose
interrupt: Effect.interrupt // Effect<never, never>: cancelled
}
for (const [name, effect] of Object.entries(cases)) {
const exit = Effect.runSyncExit(effect)
if (Exit.isFailure(exit)) {
// Every reason has a _tag: Fail, Die, or Interrupt
console.log(name, "->", exit.cause.reasons.map((r) => r._tag).join(","))
}
}
Notice that the thrown TypeError and Effect.die look the same: both are Die. Only Effect.fail appears in the type, as string. Replace Effect.sync in the thrown case with Effect.try({ try: ..., catch: () => "typed now" }). The reason becomes Fail, because you told Effect that the throw is expected.
Lesson 2. Defining errors: tagged classes you can yield #
In plain TypeScript, a custom error is a class that extends Error, and you throw it. The signature does not mention it:
class NotFound extends Error {
constructor(readonly id: number) { super("user " + id + " not found") }
}
function loadName(id: number): string { // says nothing about NotFound
const user = users.get(id)
if (!user) throw new NotFound(id)
return user.name
}Effect errors are classes too, with 2 differences. First, they have a _tag: a string literal that names the error. Effect and TypeScript use the tag to tell error types apart in a union. Second, you can yield them. Inside Effect.gen, you write yield* new NotFound({ id }), and the effect fails with that error. This reads like throw, but the error goes into the E type.
There are 2 ways to define an error. Schema.TaggedError is the preferred way. You declare the fields as schemas, so you can validate the error later or send it over a network. Data.TaggedError is smaller and takes a plain field type. Both set _tag for you.
import { Cause, Data, Effect, Exit, Schema } from "effect"
// Preferred: schema-backed. _tag is "NotFound", filled in automatically.
class NotFound extends Schema.TaggedError<NotFound>()("NotFound", {
id: Schema.Number
}) {}
// Lighter: plain fields, no schema.
class Forbidden extends Data.TaggedError("Forbidden")<{
readonly id: number
readonly role: string
}> {}
const users = new Map([
[1, { name: "Ada", role: "admin" }],
[2, { name: "Lin", role: "guest" }]
])
// Inferred: Effect<string, NotFound | Forbidden>. Both errors are in the type.
const loadName = (id: number) =>
Effect.gen(function* () {
const user = users.get(id)
if (user === undefined) {
return yield* new NotFound({ id }) // yield* an error instance to fail: a typed throw
}
if (user.role !== "admin") {
return yield* new Forbidden({ id, role: user.role })
}
return user.name
})
for (const id of [1, 2, 3]) {
const exit = Effect.runSyncExit(loadName(id))
if (Exit.isSuccess(exit)) {
console.log(id, "ok", exit.value)
} else {
const error = Cause.squash(exit.cause) as NotFound | Forbidden
// _tag narrows the union, so the right field is available in each branch
console.log(id, error._tag, error._tag === "NotFound" ? "id " + error.id : "role " + error.role)
}
}
Hover over loadName in an editor. TypeScript collected both errors from the 2 yield* lines into NotFound | Forbidden. Change yield* new NotFound({ id }) to throw new NotFound({ id }). The code still compiles, but the error disappears from the type and becomes a Die. Challenge 5 is about this mistake.
Lesson 3. Recovering by tag: catch, catchTag, catchTags #
When errors have tags, recovery is a lookup by tag, not a chain of instanceof checks. Each function below removes the errors that it catches from E and keeps the rest.
| Function | Catches | E afterwards |
|---|---|---|
Effect.catch(f) |
Every failure | The failure type of f |
Effect.catchTag("A", f) |
Only errors with tag A |
The union without A |
Effect.catchTag(["A", "B"], f) |
A or B, one handler |
The union without A and B |
Effect.catchTags({ A: f, B: g }) |
Several tags, one handler for each | The union without those tags |
Effect.catchIf(pred, f) |
Errors that satisfy a condition | Lesson 4 |
Effect.catchFilter(filter, f) |
Errors that satisfy a Filter |
Lesson 4 |
The handler receives the error, already narrowed to the matched class, and returns a new effect. The success type of this effect is added to A, and its error type is added to E. If the handler returns Effect.succeed(...), the tag is removed from E. This is the smaller error type from the intro. The return type annotations in the code make it visible.
Note: catchAll from older Effect versions is now Effect.catch.
import { Effect, Schema } from "effect"
class NotFound extends Schema.TaggedError<NotFound>()("NotFound", { id: Schema.Number }) {}
class Forbidden extends Schema.TaggedError<Forbidden>()("Forbidden", { role: Schema.String }) {}
class Timeout extends Schema.TaggedError<Timeout>()("Timeout", { ms: Schema.Number }) {}
const loadName = (id: number): Effect.Effect<string, NotFound | Forbidden | Timeout> =>
id === 1 ? Effect.succeed("Ada")
: id === 2 ? Effect.fail(new Forbidden({ role: "guest" }))
: id === 3 ? Effect.fail(new Timeout({ ms: 500 }))
: Effect.fail(new NotFound({ id }))
// One tag handled. E shrinks to Forbidden | Timeout.
const step1 = (id: number): Effect.Effect<string, Forbidden | Timeout> =>
loadName(id).pipe(
Effect.catchTag("NotFound", (e) => Effect.succeed("nobody #" + e.id))
)
// The remaining tags, one handler each. E shrinks to never.
const safeName = (id: number): Effect.Effect<string, never> =>
step1(id).pipe(
Effect.catchTags({
Forbidden: (e) => Effect.succeed("hidden (" + e.role + ")"),
Timeout: (e) => Effect.succeed("slow (" + e.ms + "ms)")
})
)
// Array form for a shared handler, then catch for whatever is left (only Timeout here).
const fallback = (id: number): Effect.Effect<string, never> =>
loadName(id).pipe(
Effect.catchTag(["NotFound", "Forbidden"], (e) => Effect.succeed("no access: " + e._tag)),
Effect.catch((e) => Effect.succeed("other: " + e._tag))
)
for (const id of [1, 2, 3, 4]) {
console.log(id, Effect.runSync(safeName(id)), "|", Effect.runSync(fallback(id)))
}
Delete the Timeout handler inside catchTags. safeName no longer compiles: Timeout is still in E, but the annotation declares never. The compiler does the check that a code reviewer did before.
Lesson 4. Recovering by condition, and translating errors #
Sometimes the tag is not enough. An HttpError with status 503 can use a cached fallback. The same class with status 404 cannot. 2 functions look at the error value:
Effect.catchIf(predicate, handler). With a plain boolean predicate,Edoes not become smaller. A 404 is still anHttpErrorthat can come out. With a type guard ((e): e is HttpError => ...), Effect removes the matched class fromE. Use a guard only when the guard matches every value of that class.Effect.catchFilter(filter, handler). AFilter(from theFiltermodule) is a reusable, named version of the same idea.Filter.tagged("ParseError")matches a tag and narrows the type correctly.Filter.fromPredicate(fn)wraps a boolean function.
The third function does not recover at all. Effect.mapError(f) replaces the error with another value. Use it at a boundary. A low-level HttpError becomes the AppError of your domain, and callers see only the domain error. Without this step, every caller must know about HTTP.
import { Cause, Effect, Exit, Filter, Schema } from "effect"
class HttpError extends Schema.TaggedError<HttpError>()("HttpError", { status: Schema.Number }) {}
class ParseError extends Schema.TaggedError<ParseError>()("ParseError", { input: Schema.String }) {}
class AppError extends Schema.TaggedError<AppError>()("AppError", { message: Schema.String }) {}
const request = (status: number): Effect.Effect<string, HttpError | ParseError> =>
status === 200 ? Effect.succeed("body")
: status === 0 ? Effect.fail(new ParseError({ input: "<html>" }))
: Effect.fail(new HttpError({ status }))
const load = (status: number): Effect.Effect<string, AppError> =>
request(status).pipe(
// 1. A plain predicate: recover only when the value says so. E does not shrink.
Effect.catchIf(
(e) => e._tag === "HttpError" && e.status >= 500,
() => Effect.succeed("cached copy")
),
// 2. A reusable Filter on the tag. ParseError leaves E here.
Effect.catchFilter(
Filter.tagged("ParseError"),
(e) => Effect.succeed("empty (could not parse " + e.input + ")")
),
// 3. Translate what is left (only HttpError now) into the domain error.
Effect.mapError((e) => new AppError({ message: "request failed with " + e.status }))
)
for (const status of [200, 503, 0, 404]) {
const exit = Effect.runSyncExit(load(status))
console.log(status, Exit.isSuccess(exit) ? exit.value : "AppError: " + (Cause.squash(exit.cause) as AppError).message)
}
Inside the mapError handler, the type of e is only HttpError, because catchFilter already removed ParseError. Swap steps 2 and 3. The mapError handler must now process both classes, and e.status no longer compiles.
Lesson 5. Error channel operators: filterOrFail, mapBoth, flip, firstSuccessOf #
The catch functions remove errors. This lesson has 4 functions that work with the error channel in other ways. In plain TypeScript, each one is an if with a throw, or a try/catch that throws a new error. The last one is a chain of try/catch blocks with fallbacks.
| Function | What it does | Use it when |
|---|---|---|
Effect.filterOrFail(test, orFailWith) |
Tests the success value. If the test is false, it fails with the error from orFailWith |
A value must satisfy a rule before the next step |
Effect.mapBoth({ onFailure, onSuccess }) |
Changes the error and the success value in one step | Both channels change at a boundary |
Effect.flip(effect) |
Swaps the 2 channels: Effect<A, E> becomes Effect<E, A> |
A test expects a failure, or you process the error as a value |
Effect.firstSuccessOf([a, b, c]) |
Runs the effects in order and stops at the first success | Several sources give the same value, with fallbacks |
filterOrFail adds the error from orFailWith to E. When you omit orFailWith, it fails with NoSuchElementError. mapBoth is map and mapError in one call. firstSuccessOf fails only when every effect fails, with the error of the last one.
Note: Effect v4 has no orElseFail, no orElse, and no merge. Use mapError to replace an error, catch to try another effect, and match to get one plain value from both outcomes.
import { Effect, Schema } from "effect"
class TooSmall extends Schema.TaggedError<TooSmall>()("TooSmall", { amount: Schema.Number }) {}
class AppError extends Schema.TaggedError<AppError>()("AppError", { message: Schema.String }) {}
// 1. filterOrFail: keep the value if the test passes, or fail with a typed error.
const checkAmount = (amount: number): Effect.Effect<number, TooSmall> =>
Effect.succeed(amount).pipe(
Effect.filterOrFail((n) => n >= 10, (n) => new TooSmall({ amount: n }))
)
// 2. mapBoth: change both channels in one step. number -> string, TooSmall -> AppError.
const describe = (amount: number): Effect.Effect<string, AppError> =>
checkAmount(amount).pipe(
Effect.mapBoth({
onFailure: (e) => new AppError({ message: "amount " + e.amount + " is too small" }),
onSuccess: (n) => "accepted " + n
})
)
// 3. flip: the error becomes the success value. A test that expects a failure reads it directly.
const expectFailure = (amount: number): Effect.Effect<AppError, string> => Effect.flip(describe(amount))
// 4. firstSuccessOf: try the sources in order. The first success wins, and the rest do not run.
const fromCache = (key: string): Effect.Effect<string, string> =>
key === "a" ? Effect.succeed("cache:" + key) : Effect.fail("cache miss")
const fromDb = (key: string): Effect.Effect<string, string> =>
key === "b" ? Effect.succeed("db:" + key) : Effect.fail("db miss")
const lookup = (key: string) => Effect.firstSuccessOf([fromCache(key), fromDb(key), Effect.succeed("default")])
const program = Effect.gen(function* () {
console.log(yield* describe(25))
const error = yield* expectFailure(3)
console.log("expected failure:", error.message)
for (const key of ["a", "b", "c"]) {
console.log(key, "->", yield* lookup(key))
}
})
Effect.runSync(program)
Change expectFailure(3) to expectFailure(25). The program now fails, because after flip the success "accepted 25" is the error. Remove Effect.succeed("default") from lookup. For the key "c" the program fails with "db miss", the error of the last source.
Lesson 6. Collect every error, not only the first: partition and validate #
A form has 4 fields, and 2 of them are empty. With Effect.forEach, the check stops at the first empty field. The user corrects one field, sends the form again, and sees the next error. A good form shows all errors at once. Effect has 3 ways to run every element and keep every outcome:
| Function | Runs every element? | Result type | Use it when |
|---|---|---|---|
Effect.forEach(items, f) |
No. It stops at the first failure | Effect<Array<B>, E> |
One bad element must stop the work |
Effect.partition(items, f) |
Yes | Effect<[Array<E>, Array<B>], never> |
You process both lists, and the work must not fail |
Effect.validate(items, f) |
Yes | Effect<Array<B>, NonEmptyArray<E>> |
One form, all messages at once, then a failure |
Effect.all({ a, b }, { mode: "result" }) |
Yes | Effect<{ a: Result<A, E>; ... }, never> |
A fixed set of effects, each outcome by key |
Effect.forEach(items, (x) => Effect.result(f(x))) |
Yes | Effect<Array<Result<B, E>>, never> |
You need each outcome in order |
partition gives a tuple. The failures come first, then the successes. validate fails with a non-empty array of every error, or succeeds with every result.
Effect.all without mode stops at the first failure, also with the concurrency option. It interrupts the other fibers. The Cause holds only 1 Fail reason. The interrupted fibers do not appear in it.
Note: Effect v3 had validateAll and validateFirst. Effect v4 has only validate.
import { Cause, Effect, Exit, Result, Schema } from "effect"
class Invalid extends Schema.TaggedError<Invalid>()("Invalid", { sku: Schema.String }) {}
const check = (sku: string): Effect.Effect<string, Invalid> =>
sku.startsWith("sku-") ? Effect.succeed(sku.toUpperCase()) : Effect.fail(new Invalid({ sku }))
const skus = ["sku-1", "bad-2", "sku-3", "bad-4"]
const program = Effect.gen(function* () {
// 1. forEach stops at the first failure. sku-3 and bad-4 never run.
const first = yield* Effect.result(Effect.forEach(skus, check))
console.log("forEach:", Result.isFailure(first) ? "stopped at " + first.failure.sku : "all ok")
// 2. partition runs every element and never fails. Failures on the left, successes on the right.
const [failed, ok] = yield* Effect.partition(skus, check)
console.log("partition:", failed.map((e) => e.sku).join(","), "|", ok.join(","))
// 3. validate runs every element. It fails with every error in a non-empty array.
const validated = yield* Effect.result(Effect.validate(skus, check))
console.log("validate:", Result.isFailure(validated) ? validated.failure.length + " errors" : "all ok")
// 4. all with mode "result": every entry is a Result, with the same keys as the input.
const results = yield* Effect.all({ a: check("sku-1"), b: check("bad-2") }, { mode: "result" })
console.log("all result:", results.a._tag, results.b._tag)
// 5. forEach with Effect.result: one Result per element, in input order, also with concurrency.
const each = yield* Effect.forEach(skus, (sku) => Effect.result(check(sku)), { concurrency: "unbounded" })
console.log("forEach+result:", each.map((r) => (Result.isSuccess(r) ? "ok" : "fail")).join(","))
// 6. all without mode: the first failure wins, also with concurrency. The cause has 1 reason.
const exit = yield* Effect.exit(Effect.all([check("bad-2"), check("bad-4")], { concurrency: "unbounded" }))
if (Exit.isFailure(exit)) {
console.log("all cause:", exit.cause.reasons.length, "reason,", (Cause.squash(exit.cause) as Invalid).sku)
}
})
Effect.runSync(program)
Hover over validated in an editor. The failure side is NonEmptyArray<Invalid>, so validated.failure[0] is always present. Change check so that every sku is valid. validate then succeeds with 4 strings, and partition gives an empty first list.
Lesson 7. Turning a failure into a plain value #
Every function so far replaced a failure with another effect. Often you want a simpler result: a default value, or both outcomes as data. This family changes an effect that can fail into an effect that cannot fail.
| Function | Use when | Result type |
|---|---|---|
Effect.orElseSucceed(() => x) |
Every failure must become a default value | Effect<A | X, never> |
Effect.match({ onFailure, onSuccess }) |
Both outcomes become a plain value | Effect<B, never> |
Effect.matchEffect({ onFailure, onSuccess }) |
Same, but the handlers run effects | Effect<B, E2> |
Effect.result(effect) |
You keep the error for a later check | Effect<Result<A, E>, never> |
Effect.option(effect) |
You only want to know if the effect succeeded | Effect<Option<A>, never> |
Effect.ignore(effect) |
You discard both outcomes | Effect<void, never> |
Result is the Effect data type for "success or failure" (older versions called it Either). Option is the data type for "a value or nothing". Both have guards such as isSuccess and isSome.
Note: like catch, all of these functions see failures only. A defect passes through them.
import { Effect, Option, Result, Schema } from "effect"
class NotFound extends Schema.TaggedError<NotFound>()("NotFound", { key: Schema.String }) {}
const settings = new Map([["theme", "dark"]])
const get = (key: string): Effect.Effect<string, NotFound> => {
const value = settings.get(key)
return value === undefined ? Effect.fail(new NotFound({ key })) : Effect.succeed(value)
}
const program = Effect.gen(function* () {
// A default value. Effect<string, never>
const lang = yield* get("lang").pipe(Effect.orElseSucceed(() => "en"))
console.log("lang:", lang)
// One plain function per outcome
const theme = yield* get("theme").pipe(Effect.match({
onFailure: (e) => "missing " + e.key,
onSuccess: (v) => "theme is " + v
}))
console.log(theme)
// Same shape, but each handler is an Effect
yield* get("font").pipe(Effect.matchEffect({
onFailure: (e) => Effect.sync(() => console.log("no", e.key, "setting, using default")),
onSuccess: (v) => Effect.sync(() => console.log("font is", v))
}))
// Both outcomes as data. Effect<Result<string, NotFound>, never>
const r = yield* Effect.result(get("lang"))
console.log("result:", Result.isSuccess(r) ? r.success : "failed with " + r.failure._tag)
// Only "did it work". The error is thrown away. Effect<Option<string>, never>
const o = yield* Effect.option(get("theme"))
console.log("option:", Option.isSome(o) ? o.value : "none")
// Do not care at all. Effect<void, never>
yield* Effect.ignore(get("lang"))
console.log("still running")
})
Effect.runSync(program)
Effect.result is the clean answer to the last problem in Getting Started, where you ran a nested runPromiseExit. Replace get("theme") in the option line with get("size"). You get none, and you cannot know why. Use result when the reason is important.
Lesson 8. Defects: the errors that catch does not see #
A developer wraps a lookup in Effect.catch with a fallback. The developer thinks that every problem is now caught. Then a user id that is not in the table reaches a ! assertion that is not true. The code throws a TypeError, and the program crashes. The fallback did not run.
This is the intended behavior. The throw happened inside Effect.sync, so it is a defect, and E for that effect is never. Effect.catch, catchTag, match, result, and the related functions look only at E. A defect means that the program is in a state that nobody planned for. A default value would hide the defect, and this is the plain TypeScript bug from lesson 1.
When you want to process defects, say so explicitly:
Effect.catchDefect(f)sees only defects.freceives the thrown value asunknown.Effect.catchCause(f)sees the wholeCause: failures, defects, and interrupts together. Use it for logs at the top of an application.Cause.pretty(cause)makes a readable report. Its first line isName: message. The rest is a stack trace.
import { Cause, Effect, Exit } from "effect"
// Bug: crashes for unknown ids. The thrown TypeError becomes a defect.
const findName = (id: number) =>
Effect.sync(() => {
const rows = [{ id: 1, name: "Ada" }]
return rows.find((r) => r.id === id)!.name
})
// 1. catch cannot see it. E is never, so this handler never runs.
const naive = findName(2).pipe(Effect.catch(() => Effect.succeed("fallback")))
const exit = Effect.runSyncExit(naive)
console.log("naive:", Exit.isFailure(exit) ? exit.cause.reasons.map((r) => r._tag).join(",") : "recovered")
// 2. catchDefect sees only defects, as unknown.
const guarded = findName(2).pipe(
Effect.catchDefect((defect) =>
Effect.succeed(defect instanceof TypeError ? "recovered from a TypeError" : "recovered")
)
)
console.log("guarded:", Effect.runSync(guarded))
// 3. catchCause sees everything, and Cause.pretty describes it.
const reported = Effect.die(new Error("unreachable: cart already paid")).pipe(
Effect.catchCause((cause) =>
Effect.sync(() => {
console.log("report:", Cause.pretty(cause).split("\n")[0]) // first line; the rest is a stack trace
return "recovered"
})
)
)
console.log("reported:", Effect.runSync(reported))
The correct fix for findName is not catchDefect. The correct fix is to return Effect.fail(new NotFound({ id })) when the row does not exist. Then the case becomes a typed failure. Keep catchDefect and catchCause for boundaries such as request handlers and plugin loaders. At a boundary, a crash must become a log line and a 500 response, not a process exit.
Lesson 9. Errors with reasons: catchReason, catchReasons, unwrapReason #
Some errors have 2 levels. A payment step fails with one PaymentError, but the reason varies: card declined, not enough funds, gateway down. If you make 3 top-level errors, you lose the information that the payment step failed. If you make one error with a string reason, you lose the types. Effect v4 supports a middle path: a tagged error with a reason field that is itself a tagged union.
| Function | What it does | E afterwards |
|---|---|---|
catchReason("PaymentError", "CardDeclined", f, orElse?) |
Catches one reason | PaymentError stays (other reasons can still occur) |
catchReasons("PaymentError", { A: f, B: g }, orElse?) |
Catches several reasons | PaymentError stays unless orElse catches the rest |
unwrapReason("PaymentError") |
Replaces the parent with its reasons | A | B | C, ready for catchTags |
The handlers receive the reason object, already narrowed, not the outer error. The optional last argument, orElse, gets the reasons that you did not list. When orElse is present, the parent error leaves E.
import { Effect, Schema } from "effect"
class CardDeclined extends Schema.TaggedError<CardDeclined>()("CardDeclined", { code: Schema.String }) {}
class InsufficientFunds extends Schema.TaggedError<InsufficientFunds>()("InsufficientFunds", { missing: Schema.Number }) {}
class GatewayDown extends Schema.TaggedError<GatewayDown>()("GatewayDown", { retryAfter: Schema.Number }) {}
// One error for the payment step. The reason says why.
class PaymentError extends Schema.TaggedError<PaymentError>()("PaymentError", {
reason: Schema.Union([CardDeclined, InsufficientFunds, GatewayDown])
}) {}
const pay = (amount: number): Effect.Effect<string, PaymentError> =>
amount <= 50 ? Effect.succeed("paid " + amount)
: amount <= 100 ? Effect.fail(new PaymentError({ reason: new InsufficientFunds({ missing: amount - 50 }) }))
: amount <= 500 ? Effect.fail(new PaymentError({ reason: new CardDeclined({ code: "51" }) }))
: Effect.fail(new PaymentError({ reason: new GatewayDown({ retryAfter: 30 }) }))
// One reason. E is still PaymentError, because the other reasons can happen.
const one = (amount: number): Effect.Effect<string, PaymentError> =>
pay(amount).pipe(
Effect.catchReason("PaymentError", "InsufficientFunds", (r) => Effect.succeed("top up " + r.missing))
)
// Several reasons plus a catch-all for the rest. E is never.
const many = (amount: number): Effect.Effect<string, never> =>
pay(amount).pipe(
Effect.catchReasons("PaymentError", {
InsufficientFunds: (r) => Effect.succeed("top up " + r.missing),
CardDeclined: (r) => Effect.succeed("declined, code " + r.code)
}, (r) => Effect.succeed("later: " + r._tag))
)
// The reasons become the error. E is CardDeclined | InsufficientFunds after catchTag.
const unwrapped = (amount: number): Effect.Effect<string, CardDeclined | InsufficientFunds> =>
pay(amount).pipe(
Effect.unwrapReason("PaymentError"),
Effect.catchTag("GatewayDown", (r) => Effect.succeed("retry in " + r.retryAfter + "s"))
)
for (const amount of [40, 80, 300, 900]) {
console.log(amount, "->", Effect.runSync(many(amount)))
}
console.log("one(80):", Effect.runSync(one(80)))
console.log("unwrapped(900):", Effect.runSync(unwrapped(900)))
Remove the orElse argument from many. The annotation Effect<string, never> no longer compiles, because a PaymentError with reason GatewayDown can still come out. Reasons keep "which step failed" and "why" in one value, and the compiler still checks that you caught every case.
Do and don't #
| Do | Don't | Why |
|---|---|---|
Use Effect.fail (or yield* on a tagged error) for an expected failure. | Do not use throw or Effect.die for a case that a caller must process. | A throw or Effect.die makes a defect that is not in E, so callers cannot see it or catch it by tag. |
Define errors with Schema.TaggedError and a unique _tag. | Do not fail with plain strings or classes that extend Error without a tag. | Without a _tag, catchTag and catchTags cannot select one error from the union. |
Inside Effect.gen, write return yield* new NotFound({ id }) to fail. | Do not write throw new NotFound({ id }) inside Effect.gen. | A throw inside the generator becomes a defect, the error leaves the type, and catchTag cannot see it. |
Catch one error at a time with Effect.catchTag, or several with Effect.catchTags. | Do not use Effect.catch with an instanceof chain inside the handler. | Effect.catch removes every failure from E, so the compiler no longer reports the cases that you did not process. |
Annotate the return type, for example Effect.Effect<string, never>, after you catch every error. | Do not declare never in E while one tag is still not caught. | The compiler rejects the annotation, and this type error is the only sign of the case that is not caught. |
Use Effect.mapError at a boundary to change a low-level error into a domain error. | Do not let a low-level error such as a plain string pass through a domain function. | Every caller then must know the low-level detail, and catchTag on the domain error does not match. |
Use Effect.catchDefect or Effect.catchCause only at a boundary, such as a request handler. | Do not use Effect.catchDefect to hide a bug with a default value. | A defect is a state that nobody planned for, and a default value hides the bug in the same way that plain try/catch does. |
Use Effect.exit and read cause.reasons when you need to see defects and interrupts. | Do not use Effect.option or Effect.result when a defect is possible and important. | option and result capture only typed failures, and a defect still fails the outer effect. |
Fix it #
Each program below is broken or incomplete. Make it print the expected output with zero type errors. Use hints before the solution.
1. A name from the past #
This program uses a function name from Effect v3, and it does not compile. Correct the name so that the program prints recovered: boom.
import { Effect } from "effect"
const program = Effect.fail("boom").pipe(
Effect.catchAll((e) => Effect.succeed("recovered: " + e))
)
console.log(Effect.runSync(program))
2. The tag that does not exist #
catchTag receives a tag that no error in the program has. The program does not compile, and the tag does not match at run time either. Correct the tag so that the program prints missing user 7.
import { Effect, Schema } from "effect"
class NotFound extends Schema.TaggedError<NotFound>()("NotFound", { id: Schema.Number }) {}
const loadUser = (id: number): Effect.Effect<string, NotFound> =>
id === 1 ? Effect.succeed("Ada") : Effect.fail(new NotFound({ id }))
const program = loadUser(7).pipe(
Effect.catchTag("UserNotFound", (e) => Effect.succeed("missing user " + e.id))
)
console.log(Effect.runSync(program))
3. The annotation that promises too much #
safeName declares that it cannot fail, but the code catches only one of the 2 errors. Thus it does not compile. Catch Forbidden too, and succeed with the string "hidden". The printed output must stay the same as shown.
import { Effect, Schema } from "effect"
class NotFound extends Schema.TaggedError<NotFound>()("NotFound", { id: Schema.Number }) {}
class Forbidden extends Schema.TaggedError<Forbidden>()("Forbidden", { role: Schema.String }) {}
const loadName = (id: number): Effect.Effect<string, NotFound | Forbidden> =>
id === 1 ? Effect.succeed("Ada")
: id === 2 ? Effect.fail(new Forbidden({ role: "guest" }))
: Effect.fail(new NotFound({ id }))
const safeName = (id: number): Effect.Effect<string, never> =>
loadName(id).pipe(
Effect.catchTag("NotFound", (e) => Effect.succeed("nobody #" + e.id))
)
console.log(Effect.runSync(safeName(1)))
console.log(Effect.runSync(safeName(3)))
4. The fallback that never runs #
parseCount throws on bad input, so the program crashes, although it has a fallback. Do not change parseCount. Change the way that the code catches the failure, so that the program prints count: 0 (fallback).
import { Effect } from "effect"
const parseCount = (raw: string) =>
Effect.sync(() => {
const n = Number(raw)
if (Number.isNaN(n)) throw new Error("not a number: " + raw)
return n
})
const program = parseCount("abc").pipe(
Effect.catch(() => Effect.succeed(0)),
Effect.map((n) => "count: " + n + (n === 0 ? " (fallback)" : ""))
)
console.log(Effect.runSync(program))
5. throw is not yield* #
The generator uses throw to report a user that does not exist. Thus the error becomes a defect, and catchTag cannot see it. The program does not compile. Make the effect fail in the Effect way, so that the program prints the 2 lines below.
import { Effect, Schema } from "effect"
class NotFound extends Schema.TaggedError<NotFound>()("NotFound", { id: Schema.Number }) {}
const users = new Map([[1, "Ada"]])
const loadName = (id: number) =>
Effect.gen(function* () {
const name = users.get(id)
if (name === undefined) throw new NotFound({ id })
return name
})
const describe = (id: number) =>
loadName(id).pipe(
Effect.catchTag("NotFound", (e) => Effect.succeed("no user with id " + e.id))
)
console.log(Effect.runSync(describe(1)))
console.log(Effect.runSync(describe(2)))
6. Translate at the boundary #
loadConfig declares that it fails only with ConfigError, but readFile fails with a plain string. Thus the program does not compile. Make the declaration true. Do not change readFile or the annotation. The output must stay the same as shown.
import { Effect, Schema } from "effect"
class ConfigError extends Schema.TaggedError<ConfigError>()("ConfigError", { message: Schema.String }) {}
// A low-level helper you do not own. It fails with a string.
const readFile = (path: string): Effect.Effect<string, string> =>
path === "app.json" ? Effect.succeed('{"port":8080}') : Effect.fail("ENOENT " + path)
// The domain function. Callers should only ever see ConfigError.
const loadConfig = (path: string): Effect.Effect<string, ConfigError> =>
readFile(path)
const program = Effect.gen(function* () {
const raw = yield* loadConfig("app.json")
console.log("loaded", raw)
const fallback = yield* loadConfig("missing.json").pipe(Effect.catch(() => Effect.succeed("{}")))
console.log("fallback", fallback)
})
Effect.runSync(program)
7. One error is not enough #
The form check must report every empty field, but it reports only the first one. Change the function that runs check over the fields, so that the program prints invalid: name, age. Do not change check.
import { Effect, Schema } from "effect"
class Invalid extends Schema.TaggedError<Invalid>()("Invalid", { field: Schema.String }) {}
const form: Record<string, string> = { name: "", email: "ada@example.com", age: "" }
const check = (field: string): Effect.Effect<string, Invalid> =>
form[field] === "" ? Effect.fail(new Invalid({ field })) : Effect.succeed(form[field] ?? "")
const report = Effect.forEach(["name", "email", "age"], check).pipe(
Effect.map(() => "form is valid"),
Effect.catch((e) => Effect.succeed("invalid: " + e.field))
)
console.log(Effect.runSync(report))
8. The failure that has no name #
checkAmount declares that it fails with TooSmall, but the program does not compile. The output is already correct. Make the declaration true. Do not change the annotation, the test, or the output.
import { Effect, Exit, Schema } from "effect"
class TooSmall extends Schema.TaggedError<TooSmall>()("TooSmall", { amount: Schema.Number }) {}
const checkAmount = (amount: number): Effect.Effect<number, TooSmall> =>
Effect.succeed(amount).pipe(
Effect.filterOrFail((n) => n >= 10)
)
for (const amount of [25, 3]) {
const exit = Effect.runSyncExit(checkAmount(amount))
console.log(amount, Exit.isSuccess(exit) ? "accepted" : "rejected")
}
9. Reasons stay wrapped #
The handlers in catchTags are written for the reasons, but the effect fails with the outer ApiError. Thus the handlers never match, and the program does not compile. Add one step before catchTags so that the program prints the 3 lines below.
import { Effect, Schema } from "effect"
class RateLimited extends Schema.TaggedError<RateLimited>()("RateLimited", { retryAfter: Schema.Number }) {}
class Unauthorized extends Schema.TaggedError<Unauthorized>()("Unauthorized", { scope: Schema.String }) {}
class ApiError extends Schema.TaggedError<ApiError>()("ApiError", {
reason: Schema.Union([RateLimited, Unauthorized])
}) {}
const call = (n: number): Effect.Effect<string, ApiError> =>
n === 1 ? Effect.succeed("200 OK")
: n === 2 ? Effect.fail(new ApiError({ reason: new RateLimited({ retryAfter: 30 }) }))
: Effect.fail(new ApiError({ reason: new Unauthorized({ scope: "orders:read" }) }))
const describe = (n: number): Effect.Effect<string, never> =>
call(n).pipe(
Effect.catchTags({
RateLimited: (r) => Effect.succeed("wait " + r.retryAfter + "s"),
Unauthorized: (r) => Effect.succeed("need scope " + r.scope)
})
)
for (const n of [1, 2, 3]) console.log(Effect.runSync(describe(n)))
10. Keep the reason #
The report must say why a lookup failed, but the program only knows that it failed. Change the way that the code captures the outcome, so that the second line prints lang: NotFound. Do not change get.
import { Effect, Option, Schema } from "effect"
class NotFound extends Schema.TaggedError<NotFound>()("NotFound", { key: Schema.String }) {}
const settings = new Map([["theme", "dark"]])
const get = (key: string): Effect.Effect<string, NotFound> => {
const value = settings.get(key)
return value === undefined ? Effect.fail(new NotFound({ key })) : Effect.succeed(value)
}
const report = (key: string) =>
Effect.gen(function* () {
const outcome = yield* Effect.option(get(key))
console.log(key + ": " + (Option.isSome(outcome) ? outcome.value : "failed"))
})
Effect.runSync(report("theme"))
Effect.runSync(report("lang"))
Build it #
Write the program from the spec. The output must match exactly.
1. Checkout pipeline #
Build a checkout with 2 steps and typed errors.
- Define
OutOfStock(fieldsku: string) andCardDeclined(fieldcode: string) withSchema.TaggedError. reserve(order)fails withOutOfStockwhen theskuof the order is"sku-9". Otherwise it succeeds with the order.charge(order)fails withCardDeclinedwith code"05"whenorder.totalis more than100. Otherwise it succeeds with the string"receipt #" + order.id + " for " + order.total.checkout(order)runsreserveand thenchargewithEffect.gen. Its type must beEffect<string, OutOfStock | CardDeclined>.describe(order)catches both errors withcatchTags, so its error type isnever.OutOfStockbecomes"out of stock: " + sku.CardDeclinedbecomes"card declined (code " + code + ")".
Run describe on the 3 orders in the starter and print order.id + ": " + result for each order. Exact output:
1: receipt #1 for 30
2: out of stock: sku-9
3: card declined (code 05)import { Effect, Schema } from "effect"
interface Order {
readonly id: number
readonly sku: string
readonly total: number
}
// TODO: OutOfStock and CardDeclined with Schema.TaggedError
// TODO: reserve(order): Effect<Order, OutOfStock>
// TODO: charge(order): Effect<string, CardDeclined>
const checkout = (order: Order) =>
Effect.gen(function* () {
// TODO: reserve, then charge
return "TODO"
})
// TODO: describe(order) handles both errors with catchTags
const orders: Array<Order> = [
{ id: 1, sku: "sku-1", total: 30 },
{ id: 2, sku: "sku-9", total: 30 },
{ id: 3, sku: "sku-1", total: 250 }
]
for (const order of orders) {
// TODO: print order.id + ": " + describe(order)
}
2. Config file inspector #
Classify every way in which a config load can end. Read the Exit to do this.
The starter gives you a fake file system files and a readFile(path) that already returns a typed effect. It fails with NotFound for unknown paths and with Forbidden for "secret.json". Add:
parsePort(raw)usesEffect.syncwithJSON.parse(raw)and returns.portas anumber. A corrupt file makesJSON.parsethrow. Keep this as a defect. It is a bug in the file, not an expected case.load(path)reads and then parses, withEffect.gen.inspect(path)captures the outcome withEffect.exitand prints one line. On success, printpath + " -> ok port " + port. Otherwise, print one line for each reason incause.reasons. For aFail, printpath + " -> failed: " + tag, wheretagis the_tagof the error. For aDie, printpath + " -> crashed: " + name, wherenameis the.nameof the defect. The defect is anError.
Run inspect on "app.json", "missing.json", "secret.json", "corrupt.json" in that order. Exact output:
app.json -> ok port 8080
missing.json -> failed: NotFound
secret.json -> failed: Forbidden
corrupt.json -> crashed: SyntaxErrorimport { Effect, Exit, Schema } from "effect"
class NotFound extends Schema.TaggedError<NotFound>()("NotFound", { path: Schema.String }) {}
class Forbidden extends Schema.TaggedError<Forbidden>()("Forbidden", { path: Schema.String }) {}
const files = new Map([
["app.json", '{"port": 8080}'],
["secret.json", '{"port": 1}'],
["corrupt.json", "{port: oops"]
])
const readFile = (path: string): Effect.Effect<string, NotFound | Forbidden> => {
if (path === "secret.json") return Effect.fail(new Forbidden({ path }))
const content = files.get(path)
return content === undefined ? Effect.fail(new NotFound({ path })) : Effect.succeed(content)
}
// TODO: parsePort(raw) with Effect.sync and JSON.parse
// TODO: load(path) = read then parse
const inspect = (path: string) =>
Effect.gen(function* () {
// TODO: capture the Exit of load(path) and print one line per outcome
})
for (const path of ["app.json", "missing.json", "secret.json", "corrupt.json"]) {
Effect.runSync(inspect(path))
}
3. Gateway status messages #
An API client fails with one ApiError. Its reason says what happened. Change each outcome into a message for the user.
- Define 3 reasons with
Schema.TaggedError:RateLimited(retryAfter: number),Unauthorized(scope: string),Maintenance(until: string). DefineApiErrorwith areasonfield that is aSchema.Unionof the 3 reasons. call(n)returnsEffect<string, ApiError>.n = 1succeeds with"ok 200".n = 2fails withRateLimited(retryAfter 30).n = 3fails withUnauthorized(scope"orders:read"). Any othernfails withMaintenance(until"06:00").message(n)usesEffect.catchReasons. It changesRateLimitedinto"wait " + retryAfter + "s"andUnauthorizedinto"need scope " + scope. The third argument catches any other reason and gives"try again after " + until. The error type ofmessagemust benever.
Print "call " + n + ": " + message for n from 1 to 4. Exact output:
call 1: ok 200
call 2: wait 30s
call 3: need scope orders:read
call 4: try again after 06:00import { Effect, Schema } from "effect"
// TODO: RateLimited, Unauthorized, Maintenance reasons
// TODO: ApiError with a reason field (Schema.Union of the three)
// TODO: call(n): Effect<string, ApiError>
// TODO: message(n): Effect<string, never> with Effect.catchReasons and a catch-all
for (const n of [1, 2, 3, 4]) {
// TODO: print "call " + n + ": " + message
}
Recall #
Answer in your head first, then reveal. Come back to these tomorrow.
What is the difference between `Effect.fail` and `Effect.die`, and which catch functions see each one? #
Effect.fail(e) is an expected failure. e goes into the E type, and catch, catchTag, catchTags, catchIf, match, result, and option all see it. Effect.die(x) is a defect. An exception thrown inside Effect.sync is also a defect. A defect is not in the type. Only catchDefect and catchCause see it.
What is the type of `Effect.fail(new NotFound({ id: 1 })).pipe(Effect.catchTag("NotFound", () => Effect.succeed(0)))`? #
Effect<number, never, never>. The original effect is Effect<never, NotFound>. The handler removes NotFound from E and adds number to A. Nothing is left in the error type.
You have `Effect<User, NotFound | Forbidden | Timeout>`. You want a different fallback for `NotFound` and for `Forbidden`, and you want to keep `Timeout` in `E`. Which function do you use? #
Effect.catchTags({ NotFound: ..., Forbidden: ... }). One handler for each tag, and Timeout stays in E. If both tags must get the same handler, Effect.catchTag(["NotFound", "Forbidden"], ...) is shorter.
Inside `Effect.gen`, why is `throw new NotFound({ id })` wrong, and what must you write in its place? #
throw makes a defect. The error leaves the type, and catchTag cannot see it. Write yield* new NotFound({ id }) (usually return yield* ...). You can yield tagged errors, so this fails the effect with NotFound in E.
What does `cause.reasons` contain on a failed `Exit`, and what are the three tags? #
A flat array of reasons. Each reason has a _tag: Fail (with .error, the typed value), Die (with .defect, an unknown), or Interrupt (with .fiberId). In v4 there is no tree of sequential or parallel causes, only this array.
When do you use `Effect.unwrapReason` in place of `Effect.catchReasons`? #
catchReasons catches some reasons and keeps the parent error in E for the other reasons. unwrapReason replaces the parent with its reasons. Then E becomes the union of the reason types, and you continue with catchTags or any other function. Use it when the information "which step failed" is no longer necessary.
`Effect.result`, `Effect.option`, `Effect.exit`: which one shows a defect? #
Only Effect.exit. result and option capture typed failures only. A defect still fails the outer effect. Exit holds the full Cause, which includes Die and Interrupt reasons.
In `Effect.catchIf`, how does the result `E` change when you pass a type guard and not a plain boolean predicate? #
With a type guard (e): e is HttpError => ..., Effect removes HttpError from E. Use a guard only when it matches every value of that class. With a plain predicate, E does not change. This is correct when the handler recovers only some values (for example, status 500 and above).