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 the E type parameter.
  • Effect.die(x) is a defect, a bug. An exception thrown inside Effect.sync is also a defect. Defects are not in the type. E stays never.
  • 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(","))
  }
}
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).
Notice

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)
  }
}
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).
Notice

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)))
}
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).
Notice

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, E does not become smaller. A 404 is still an HttpError that can come out. With a type guard ((e): e is HttpError => ...), Effect removes the matched class from E. Use a guard only when the guard matches every value of that class.
  • Effect.catchFilter(filter, handler). A Filter (from the Filter module) 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)
}
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).
Notice

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)
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).
Notice

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)
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).
Notice

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)
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).
Notice

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. f receives the thrown value as unknown.
  • Effect.catchCause(f) sees the whole Cause: 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 is Name: 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))
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).
Notice

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)))
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).
Notice

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 #

DoDon'tWhy
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.

expected output: recovered: boom
import { Effect } from "effect"

const program = Effect.fail("boom").pipe(
  Effect.catchAll((e) => Effect.succeed("recovered: " + e))
)

console.log(Effect.runSync(program))
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).

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.

expected output: 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))
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).

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.

expected output: Ada nobody #3
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)))
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).

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).

expected output: 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))
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).

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.

expected output: Ada no user with id 2
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)))
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).

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.

expected output: loaded {"port":8080} fallback {}
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)
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).

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.

expected output: invalid: name, age
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))
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).

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.

expected output: 25 accepted 3 rejected
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")
}
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).

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.

expected output: 200 OK wait 30s need scope orders:read
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)))
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).

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.

expected output: theme: dark lang: NotFound
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"))
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).

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.

  1. Define OutOfStock (field sku: string) and CardDeclined (field code: string) with Schema.TaggedError.
  2. reserve(order) fails with OutOfStock when the sku of the order is "sku-9". Otherwise it succeeds with the order.
  3. charge(order) fails with CardDeclined with code "05" when order.total is more than 100. Otherwise it succeeds with the string "receipt #" + order.id + " for " + order.total.
  4. checkout(order) runs reserve and then charge with Effect.gen. Its type must be Effect<string, OutOfStock | CardDeclined>.
  5. describe(order) catches both errors with catchTags, so its error type is never. OutOfStock becomes "out of stock: " + sku. CardDeclined becomes "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)
expected 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)
}
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).

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:

  1. parsePort(raw) uses Effect.sync with JSON.parse(raw) and returns .port as a number. A corrupt file makes JSON.parse throw. Keep this as a defect. It is a bug in the file, not an expected case.
  2. load(path) reads and then parses, with Effect.gen.
  3. inspect(path) captures the outcome with Effect.exit and prints one line. On success, print path + " -> ok port " + port. Otherwise, print one line for each reason in cause.reasons. For a Fail, print path + " -> failed: " + tag, where tag is the _tag of the error. For a Die, print path + " -> crashed: " + name, where name is the .name of the defect. The defect is an Error.

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: SyntaxError
expected output: app.json -> ok port 8080 missing.json -> failed: NotFound secret.json -> failed: Forbidden corrupt.json -> crashed: SyntaxError
import { 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))
}
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).

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.

  1. Define 3 reasons with Schema.TaggedError: RateLimited (retryAfter: number), Unauthorized (scope: string), Maintenance (until: string). Define ApiError with a reason field that is a Schema.Union of the 3 reasons.
  2. call(n) returns Effect<string, ApiError>. n = 1 succeeds with "ok 200". n = 2 fails with RateLimited (retryAfter 30). n = 3 fails with Unauthorized (scope "orders:read"). Any other n fails with Maintenance (until "06:00").
  3. message(n) uses Effect.catchReasons. It changes RateLimited into "wait " + retryAfter + "s" and Unauthorized into "need scope " + scope. The third argument catches any other reason and gives "try again after " + until. The error type of message must be never.

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:00
expected output: call 1: ok 200 call 2: wait 30s call 3: need scope orders:read call 4: try again after 06:00
import { 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
}
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).

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).