Effect v4Module 3 of 8: Failures with meaning
Module 3 of 8Failure modelsrc/course/examples/errors.ts

An empty result is not an outage

Make empty results, bad requests, and provider outages distinct outcomes in the type system.

First principle

A failure is part of an operation's result. If the program erases the distinction, the user receives the wrong response.

03 / 08module position
01

The concrete friction

JavaScript exceptions destroy error type information

In JavaScript, throw is an out-of-band jump that bypasses the type system. A Promise<T> only types the success path T; rejected promises are typed as unknown or any in catch blocks. To prevent unhandled crashes, developers frequently catch all errors and return defaults like [] or null. This conflates three fundamentally different states: valid search with 0 hits, invalid client syntax (400), and upstream provider downtime (503).

First-principles consequence

An empty collection [] is a successful result with cardinality zero ([] in Success). A provider outage is an infrastructure failure (503 in Error). Erasing this distinction makes a catastrophic database outage look identical to "no issues found", misleading users and suppressing monitoring alerts.

baseline-friction.tsbaseline problem
// Vanilla TypeScript: hides an unavailable issue service
async function searchIssues(query: string) {
  try {
    const response = await fetch(
      "/api/issues?q=" + encodeURIComponent(query)
    );
    if (!response.ok) throw new Error("HTTP " + response.status);
    return await response.json();
  } catch (_error: unknown) {
    // A 503 outage now looks exactly like a successful empty search.
    return [];
  }
}
02

The mental model

Success (A) and Error (E) use distinct, typed channels

Effect<A, E, R> models expected domain failures as first-class data in the E channel. searchIssues returns Effect<ReadonlyArray<string>, InvalidQuery | IssueServiceUnavailable>. An empty array [] is delivered through the success channel A. Failures are tagged objects delivered through the error channel E. Callers must handle every tagged error variant or let the compiler propagate it.

searchIssues(query): Effect<readonly string[], SearchError>
                             │
             ┌───────────────┴───────────────┐
             │                               │
       Success channel (A)              Error channel (E)
       ["ISSUE-101"] or []              InvalidQuery
                                        IssueServiceUnavailable

[] means the search completed successfully with no matches.
A tagged error means the search did not complete successfully.
03

Minimal working example

Executable source

This is the checked source file used by the course. Read it before you run it.

src/course/examples/errors.tsFailure model
import { Console, Effect } from "effect"

export type InvalidQuery = {
  readonly _tag: "InvalidQuery"
  readonly message: string
}

export const InvalidQuery = (
  fields: Omit<InvalidQuery, "_tag">
): InvalidQuery => ({
  _tag: "InvalidQuery",
  ...fields
})

export type IssueServiceUnavailable = {
  readonly _tag: "IssueServiceUnavailable"
  readonly statusCode: number
}

export const IssueServiceUnavailable = (
  fields: Omit<IssueServiceUnavailable, "_tag">
): IssueServiceUnavailable => ({
  _tag: "IssueServiceUnavailable",
  ...fields
})

type SearchError = InvalidQuery | IssueServiceUnavailable

export const searchIssues = (
  query: string
): Effect.Effect<ReadonlyArray<string>, SearchError> => {
  if (query.trim() === "") {
    return Effect.fail(InvalidQuery({ message: "Enter a search term" }))
  }

  if (query === "service-down") {
    return Effect.fail(IssueServiceUnavailable({ statusCode: 503 }))
  }

  if (query === "missing") {
    return Effect.succeed([])
  }

  return Effect.succeed(["ISSUE-101: Add typed search errors"])
}

const describeSearch = (query: string) =>
  searchIssues(query).pipe(
    Effect.match({
      onSuccess: (issues) =>
        issues.length === 0
          ? "empty result is success: no issues found"
          : `success: ${issues.join(", ")}`,
      onFailure: (error) => `failure: ${error._tag}`
    })
  )

await Effect.runPromise(
  Effect.gen(function* () {
    yield* Console.log(yield* describeSearch("effect"))
    yield* Console.log(yield* describeSearch("missing"))
    yield* Console.log(yield* describeSearch(""))
    yield* Console.log(yield* describeSearch("service-down"))
  })
)
04

Execute and verify

Predict the result, run the command, and compare the output with the model.

Run this command
pnpm exec tsx src/course/examples/errors.ts
Before you run it

Which outputs are successful, including the empty result? Which outputs are failures, and why should the provider outage not become an empty result?

Reveal expected output
success: ISSUE-101: Add typed search errors
empty result is success: no issues found
failure: InvalidQuery
failure: IssueServiceUnavailable
05

Error anatomy and edge cases

The blanket catchAll recovery trap

Using Effect.catchAll(() => Effect.succeed([])) to silence compiler errors by mapping every possible failure to an empty success array.

Compiler or runtime diagnostic

The compiler infers Effect<readonly string[], never, R>, erasing the E channel to never. At runtime, the application fails silently during 503 outages, metrics report 100% success, and the frontend displays "No matching issues" instead of a retryable error banner.

Remedy

Keep distinct failure variants in the error channel E until a boundary has sufficient domain context to handle each variant. Use Effect.catchTag or Effect.match to handle specific error tags (e.g. prompt user for InvalidQuery, retry IssueServiceUnavailable).

06

Active lab exercise

src/exercises/exercise3/starter.ts

Exercise: keep search failures distinct

Report an empty result only when the search succeeds with no matches.

ObjectiveFix starter.ts so InvalidQuery and IssueServiceUnavailable remain visible as typed failures.
pnpm exec tsx src/exercises/exercise3/exercise.test.ts starter

The harness should fail against the starter. Inspect the assertion, change the starter implementation, and run it again.

Inspect starter code
Broken starter implementation
src/exercises/exercise3/starter.tsstarter
import { Effect } from "effect"

type SearchError =
  | { readonly _tag: "InvalidQuery" }
  | { readonly _tag: "IssueServiceUnavailable" }

const searchIssues = (
  query: string
): Effect.Effect<ReadonlyArray<string>, SearchError> => {
  if (query.trim() === "") {
    return Effect.fail({ _tag: "InvalidQuery" })
  }

  if (query === "service-down") {
    return Effect.fail({ _tag: "IssueServiceUnavailable" })
  }

  if (query === "missing") {
    return Effect.succeed([])
  }

  return Effect.succeed(["ISSUE-101"])
}

export const describeSearch = (query: string) =>
  searchIssues(query).pipe(
    Effect.match({
      onSuccess: (issues) =>
        issues.length === 0 ? "empty result" : `success: ${issues.join(", ")}`,
      onFailure: () => "empty result"
    })
  )
Reveal reference solution
Target reference solution
solution.tsverified solution
import { Effect } from "effect"

type SearchError =
  | { readonly _tag: "InvalidQuery" }
  | { readonly _tag: "IssueServiceUnavailable" }

const searchIssues = (
  query: string
): Effect.Effect<ReadonlyArray<string>, SearchError> => {
  if (query.trim() === "") {
    return Effect.fail({ _tag: "InvalidQuery" })
  }

  if (query === "service-down") {
    return Effect.fail({ _tag: "IssueServiceUnavailable" })
  }

  if (query === "missing") {
    return Effect.succeed([])
  }

  return Effect.succeed(["ISSUE-101"])
}

export const describeSearch = (query: string) =>
  searchIssues(query).pipe(
    Effect.match({
      onSuccess: (issues) =>
        issues.length === 0 ? "empty result" : `success: ${issues.join(", ")}`,
      onFailure: (error) => `failure: ${error._tag}`
    })
  )
Why this solution works

Keep the empty-result message in onSuccess for an empty array. In onFailure, include error._tag instead of returning the same success message for every failure.