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
Echannel ofEffect<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}`))Why two channels?
Section titled “Why two channels?”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.
Creating failures and defects
Section titled “Creating failures and defects”import { Effect } from "effect"
// Failures — tracked in EEffect.fail("boom") // Effect<never, string>
// Defects — NOT tracked in EEffect.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.
Moving between the channels
Section titled “Moving between the channels”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.
Next steps
Section titled “Next steps”- Define rich, matchable failures with Tagged Errors.
- Recover from them with Catching Errors.
- Inspect the full
Cause— failures and defects — with Result & Exit.