Skip to content

Expected vs Unexpected Errors

Effect splits every failure into one of two categories, and the distinction drives the entire API. Getting it right is the single most important idea in error management.

  • A failure is an expected error — something your domain knows can happen and is prepared to handle. Failures live in the E channel of Effect<A, E, R>, so the compiler tracks them and won’t let you forget one.

  • A defect is an unexpected error — a bug, a broken invariant, a thrown exception. Defects are not tracked in the type. They surface at the top of your program (or wherever you explicitly choose to catch them), not at every call site.

import { Effect, Schema } from "effect"
class UserNotFound extends Schema.TaggedErrorClass<UserNotFound>()(
"UserNotFound",
{ id: Schema.Number }
) {}
// A FAILURE: `UserNotFound` is expected, so it appears in the error channel.
// ┌─── Effect<User, UserNotFound>
// ▼
const findUser = (id: number) =>
id > 0
? Effect.succeed({ id, name: "Ada" })
: Effect.fail(new UserNotFound({ id }))
// A DEFECT: a violated invariant we never expect to hit. `Effect.die` records
// it as a defect, so the error channel stays `never` — callers are not asked
// to handle it.
// ┌─── Effect<number, never>
// ▼
const half = (n: number) =>
n % 2 === 0
? Effect.succeed(n / 2)
: Effect.die(new Error(`expected an even number, got ${n}`))

The error channel is a contract. If findUser can fail with UserNotFound, every caller sees that in the type and the compiler refuses to let the program type-check until the failure is handled or propagated. This is what makes Effect error handling exhaustive — you cannot silently drop an expected error.

Defects are the opposite: they represent situations that shouldn’t happen. You don’t want to thread “the JSON parser had a bug” through every function signature. Instead, the runtime carries defects invisibly until something chooses to inspect them — typically a logging boundary at the edge of your app.

import { Effect } from "effect"
// Failures — tracked in E
Effect.fail("boom") // Effect<never, string>
// Defects — NOT tracked in E
Effect.die("boom") // Effect<never, never>
// `Effect.sync` assumes the thunk never throws — if it does, the thrown value
// becomes a DEFECT.
Effect.sync(() => JSON.parse("{ not json")) // Effect<any, never>
// `Effect.try` catches the throw and turns it into a typed FAILURE.
Effect.try({
try: () => JSON.parse("{ not json"),
catch: (cause) => new Error(String(cause))
}) // Effect<any, Error>

Effect.try (and its async sibling Effect.tryPromise) is the standard way to bring throwing code into Effect as a failure. Effect.sync is for code you promise won’t throw — if it does anyway, that’s a bug, so it surfaces as a defect.

Sometimes a failure turns out to be unrecoverable, or a defect needs to be promoted to a handled failure. Effect gives you explicit converters so the transition is always visible in the type.

import { Effect, Schema } from "effect"
class ConfigError extends Schema.TaggedErrorClass<ConfigError>()(
"ConfigError",
{ message: Schema.String }
) {}
declare const loadConfig: Effect.Effect<string, ConfigError>
// `orDie` says: this failure can never be recovered — treat it as a defect.
// The error channel collapses from `ConfigError` to `never`.
const required: Effect.Effect<string> = loadConfig.pipe(Effect.orDie)

Use Effect.orDie when a typed error is, in context, truly fatal — for example config that must be present at startup. Going the other way (defect → failure) is done by catching the cause, which the catching errors page covers.