04 - Error Types: Making Errors First-Class
The E channel only pays off if the compiler can tell variants apart. A string or a bare Error carries no shape; the type system cannot narrow on it.
This step covers the discriminated tagged-error pattern that makes recovery operators (step 09) precise.
What you’ll work through
index.test.ts contains four beats. The first two introduce the two ways to write a tagged error — Data.TaggedError and a manual _tag class.
The third constructs an error union; the fourth narrows on _tag inside a switch and reads variant-specific fields.
By the end you should be able to define a tagged error, place it in E, and narrow on _tag without casts.
Mental Model
A tagged union uses a literal _tag field to distinguish members. Once the discriminant is checked, the compiler narrows to the specific variant, its fields become accessible without casts and a switch produces an exhaustiveness error if a case is missing.
Effect<User, NotFoundError | NetworkError> │ │ ▼ ▼ _tag: "NotFound" _tag: "Network" { resource, id } { url, status }
switch (error._tag) { case "NotFound": error.resource ✓ error.url ✗ case "Network": error.status ✓ error.resource ✗}Data.TaggedError
Creates a class whose instances carry an auto-set _tag, structural equality, and a single object-shaped constructor. This is the canonical error shape in Effect code.
import { Data } from "effect"
class ParseError extends Data.TaggedError("ParseError")<{ readonly input: string readonly reason: string}> {}
class NetworkError extends Data.TaggedError("NetworkError")<{ readonly url: string readonly status: number}> {}
const err = new ParseError({ input: "bad", reason: "not a number" })// ┌─── _tag: "ParseError", input: string, reason: stringerr._tag // "ParseError"err.input // "bad"Details
Data.TaggedError extends Effect.YieldableError, so an instance is itself a failing Effect and can be yield*-ed inside Effect.gen. The _tag is a literal type, not widened to string, narrowing on it works without as const.
When to Use
For every domain error in an Effect program. The constructor pattern (named fields) scales to errors with several pieces of context.
@see catchTag and catchTags in step 09 for the recovery operators that consume the _tag discriminant.
Your turn construct a `ParseError` with `{ input, reason }` and place it in the E channel via `Effect.fail`.
Manual _tag
When you cannot import from effect (libraries, shared types, generated code), the same shape can be written by hand.
class ValidationError extends Error { readonly _tag = "ValidationError" as const constructor(readonly field: string, message: string) { super(message) }}The as const is required. Without it the inferred type is string, and narrowing on _tag stops working.
When to Use
In code that should not depend on effect, or when extending an existing exception class hierarchy.
Your turn instantiate `ValidationError` with a field name and message so the manual `_tag` is preserved through `Effect.fail`.
Error Unions
A function with multiple failure modes lists every error in E. The compiler tracks the union through the entire pipeline.
import { Effect, Data } from "effect"
class NotFoundError extends Data.TaggedError("NotFoundError")<{ id: string }> {}class NetworkError extends Data.TaggedError("NetworkError")<{ status: number }> {}
// ┌─── Effect<{ name: string }, NotFoundError | NetworkError, never>// ▼const fetchUser = ( id: string,): Effect.Effect<{ name: string }, NotFoundError | NetworkError> => { if (id === "bad-url") return Effect.fail(new NetworkError({ status: 500 })) if (id === "missing") return Effect.fail(new NotFoundError({ id })) return Effect.succeed({ name: "Alice" })}Every caller can see both failure modes from the signature. Recovery operators (step 09) remove variants from the union one at a time, and the type narrows accordingly.
Your turn pick the right variant (`NotFoundError` or `NetworkError`) for each rejection branch.
Narrowing on _tag
The whole point of the tag is that the compiler narrows inside each case.
const exit = await Effect.runPromiseExit(fetchUser("missing"))if (Exit.isFailure(exit) && exit.cause.reasons[0]?._tag === "Fail") { const error = exit.cause.reasons[0].error // error: NotFoundError | NetworkError switch (error._tag) { case "NotFoundError": error.id // ✓ string break case "NetworkError": error.status // ✓ number break // Missing a case is a TypeScript error when E is a closed union. }}Your turn switch on `error._tag` and read the right field inside each branch. A missed case is a TypeScript error — the test relies on exhaustive narrowing.
_tag must match exactly
catchTag matches on the literal _tag string. A single-character difference means the handler never fires and the error stays in E — and because the type for the tag is a string literal, a typo in the catcher will still compile (especially if you cast).
// _tag is "ValidationError" — note the exact spelling.class ValidationError extends Error { readonly _tag = "ValidationError" as const constructor(readonly field: string, message: string) { super(message) }}
// ✗ Typo: "Validation" never equals "ValidationError" — the error escapes.Effect.fail(new ValidationError("email", "bad")).pipe( Effect.catch((err) => (err._tag as string) === "Validation" ? Effect.succeed("handled") : Effect.fail(err), ),)// Exit: Failure(ValidationError)
// ✓ Exact match — catchTag routes the error to the handler.Effect.fail(new ValidationError("email", "bad")).pipe( Effect.catchTag("ValidationError", () => Effect.succeed("handled")),)// Exit: Success("handled")Your turn read the "wrong tag string" assertion and confirm it's a failure exit. Then complete the second test by replacing the stub with the correct `catchTag("ValidationError", ...)` call.
Data.TaggedError vs Manual
| Feature | Manual extends Error |
Data.TaggedError |
|---|---|---|
_tag discriminant |
manual (readonly _tag = "X" as const) |
automatic |
| Structural equality | no | yes (via Equal) |
| Constructor | new E(arg1, arg2) |
new E({ field1, field2 }) |
Yieldable in Effect.gen |
no | yes |
| Recommended for Effect code | no | yes |
Rules of thumb
- One tagged error per failure mode. Discriminate by
_tag, not by message. The compiler narrows_tagexhaustively; it can’t narrow strings. - Error fields are values, not exceptions. Keep them small,
readonly, and structurally meaningful — they will be serialized, logged, and matched on. Data.TaggedErroris the canonical form. It plays directly withcatchTag,Match.tag, andEffect.catchTags.
Common Pitfalls
1, Forgetting as const. Without it the manual _tag widens to string and catchTag cannot narrow. Data.TaggedError handles this automatically.
2, string errors. Effect.fail("oops") gives Effect<never, string>. Strings cannot be discriminated; use a tagged class as soon as more than one error mode exists.
3, Plain Error with catchTag. Effect.catchTag requires a _tag field. A bare Error will not type-check.
4, Partial handling. Catching one variant of a union leaves the others in E. That is the desired behavior, the type tells you what remains.
and place it in `E` via `Effect.fail`. - [ ] Complete `manual-tag`: write the `_tag` by hand with `as const`. Remove `as const` once to see narrowing break, then restore it. - [ ] Complete `error-unions`: pick the variant that matches each rejection. - [ ] Complete `narrow-on-tag`: switch on `_tag` and read the variant-specific field. Confirm an unreachable `default` is a type error. - [ ] Note how this pattern primes `catchTag` in step 09 — the `_tag` is the handle.
See Also
- Previous,
03-result-types: the three-channelEffectsignature these errors live in. - Next,
05-effect-basics: the constructors that lift these errors intoE. - Later,
09-error-handling:catchTag,catchTags,mapError. - Related,
15-data: the widerDatamodule (Data.Class,Data.TaggedClass).
Run the tests: pnpm test 04-error-types
The full test suite
The index.test.ts for this lesson, verbatim from steps/04-error-types/.
import { it, expect } from "vite-plus/test"import { Effect, Exit, Data } from "effect"
// =============================================================================// 05 - Error Types: Making Errors First-Class// =============================================================================//// Data.TaggedError creates a class with an auto-set _tag, structural equality,// and an object-shaped constructor. Manual _tag is supported as a fallback when// `effect` cannot be imported. The discriminant is what makes catchTag work.
class ValidationError extends Error { readonly _tag = "ValidationError" as const constructor(readonly field: string, message: string) { super(message) }}
class ParseError extends Data.TaggedError("ParseError")<{ readonly input: string readonly reason: string}> {}
class NotFoundError extends Data.TaggedError("NotFoundError")<{ readonly id: string}> {}
class NetworkError extends Data.TaggedError("NetworkError")<{ readonly status: number}> {}
// @beat// id: manual-tag// shape: one-todo// title: Discriminate with a manual _tag// teaches: A literal `_tag` with `as const` makes a plain class discriminable; without `as const` it widens to string.// hint: instantiate ValidationError with the field name and a messageit("carries typed fields accessible after instanceof narrowing", () => { const program = Effect.fail( /* @stub */ new ValidationError("email", "Invalid email format") /* @end */, ) const exit = Effect.runSyncExit(program)
expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit) && exit.cause.reasons[0]?._tag === "Fail") { expect(exit.cause.reasons[0].error._tag).toBe("ValidationError") if (exit.cause.reasons[0].error instanceof ValidationError) { expect(exit.cause.reasons[0].error.field).toBe("email") expect(exit.cause.reasons[0].error.message).toBe("Invalid email format") } }})// @end-beat
// @beat// id: data-tagged-error// shape: multi-todo// title: Build domain errors with Data.TaggedError// teaches: Data.TaggedError auto-sets the `_tag`, gives an object-shaped constructor, and yields an Effect-yieldable error.// hint: construct ParseError with { input, reason } and let `_tag` be set by the classit("auto-sets _tag and exposes fields directly", () => { const error = /* @stub */ new ParseError({ input: "bad", reason: "test" }) /* @end */ expect(error._tag).toBe("ParseError") expect(error.input).toBe("bad") expect(error.reason).toBe("test")})
it("places a structured error in the E channel", () => { const program = Effect.fail( /* @stub */ new ParseError({ input: "not json", reason: "Invalid JSON syntax" }) /* @end */, ) const exit = Effect.runSyncExit(program)
if (Exit.isFailure(exit) && exit.cause.reasons[0]?._tag === "Fail") { const error = exit.cause.reasons[0].error expect(error._tag).toBe("ParseError") expect(error.input).toBe("not json") expect(error.reason).toBe("Invalid JSON syntax") }})// @end-beat
// @beat// id: error-unions// shape: multi-todo// title: List every failure in an error union// teaches: A function with multiple failure modes lists each tagged error in E; the compiler tracks the union through the pipeline.// hint: choose the variant whose tag matches the case the test is exercisingit("succeeds on the happy path", () => { // ┌─── Effect<{ name: string }, NotFoundError | NetworkError, never> // ▼ const fetchUser = ( id: string, ): Effect.Effect<{ name: string }, NotFoundError | NetworkError> => { if (id === "bad-url") return Effect.fail(new NetworkError({ status: 500 })) if (id === "missing") return Effect.fail(new NotFoundError({ id })) return Effect.succeed({ name: "Alice" }) }
expect(Effect.runSync(fetchUser("123"))).toEqual({ name: "Alice" })})
it("places NotFoundError in E", () => { const fetchUser = ( id: string, ): Effect.Effect<{ name: string }, NotFoundError | NetworkError> => { if (id === "bad-url") return Effect.fail(new NetworkError({ status: 500 })) if (id === "missing") return /* @stub */ Effect.fail(new NotFoundError({ id })) /* @end */ return Effect.succeed({ name: "Alice" }) }
const exit = Effect.runSyncExit(fetchUser("missing")) if (Exit.isFailure(exit) && exit.cause.reasons[0]?._tag === "Fail") { expect(exit.cause.reasons[0].error._tag).toBe("NotFoundError") }})
it("places NetworkError in E", () => { const fetchUser = ( id: string, ): Effect.Effect<{ name: string }, NotFoundError | NetworkError> => { if (id === "bad-url") return /* @stub */ Effect.fail(new NetworkError({ status: 500 })) /* @end */ if (id === "missing") return Effect.fail(new NotFoundError({ id })) return Effect.succeed({ name: "Alice" }) }
const exit = Effect.runSyncExit(fetchUser("bad-url")) if (Exit.isFailure(exit) && exit.cause.reasons[0]?._tag === "Fail") { expect(exit.cause.reasons[0].error._tag).toBe("NetworkError") }})// @end-beat
// @beat// id: narrow-on-tag// shape: multi-todo// title: Narrow on `_tag`// teaches: A switch (or if-chain) on `_tag` narrows to the specific variant; each branch sees only that variant's fields.// hint: switch on `error._tag` and read the field for that variant inside the caseit("exposes the specific variant's fields inside each case", () => { class DivByZero extends Data.TaggedError("DivByZero")<{ readonly op: string }> {} class Overflow extends Data.TaggedError("Overflow")<{ readonly value: number }> {}
const safeDivide = ( a: number, b: number, ): Effect.Effect<number, DivByZero | Overflow> => { if (b === 0) return Effect.fail(new DivByZero({ op: `${a}/${b}` })) if (a > 1e15) return Effect.fail(new Overflow({ value: a })) return Effect.succeed(a / b) }
const exit = Effect.runSyncExit(safeDivide(10, 0)) if (Exit.isFailure(exit) && exit.cause.reasons[0]?._tag === "Fail") { const error = exit.cause.reasons[0].error /* @stub */ switch (error._tag) { case "DivByZero": expect(error.op).toBe("10/0") break case "Overflow": throw new Error("unexpected Overflow") } /* @end */ }})
it("narrows independently for each member of the union", () => { class TooShort extends Data.TaggedError("TooShort")<{ readonly min: number }> {} class TooLong extends Data.TaggedError("TooLong")<{ readonly max: number }> {}
const validateLength = ( s: string, min: number, max: number, ): Effect.Effect<string, TooShort | TooLong> => { if (s.length < min) return Effect.fail(new TooShort({ min })) if (s.length > max) return Effect.fail(new TooLong({ max })) return Effect.succeed(s) }
const shortExit = Effect.runSyncExit(validateLength("hi", 5, 10)) if (Exit.isFailure(shortExit) && shortExit.cause.reasons[0]?._tag === "Fail") { const err = shortExit.cause.reasons[0].error if (/* @stub */ err._tag === "TooShort" /* @end */) { expect(err.min).toBe(5) } }
const longExit = Effect.runSyncExit(validateLength("x".repeat(20), 5, 10)) if (Exit.isFailure(longExit) && longExit.cause.reasons[0]?._tag === "Fail") { const err = longExit.cause.reasons[0].error if (/* @stub */ err._tag === "TooLong" /* @end */) { expect(err.max).toBe(10) } }})// @end-beat
// @beat// id: tag-matters// shape: multi-todo// title: _tag must match exactly// teaches: catchTag matches on the literal _tag string; a single character difference means the handler never fires and the error stays in E.// hint: the string you pass to catchTag must equal the _tag field value exactly — compare both sides character for character
it("a wrong _tag string leaves the error unhandled (read this)", () => { // ValidationError._tag = "ValidationError" // The handler below listens for "Validation" — one word short. // The types still compile because we cast, but the handler never fires. const program = Effect.fail(new ValidationError("email", "bad input")).pipe( Effect.catch((err) => { // Manual _tag check — simulates what catchTag does under the hood if ((err._tag as string) === "Validation") { return Effect.succeed("handled") } return Effect.fail(err) // typo means we fall through }), )
const exit = Effect.runSyncExit(program) expect(Exit.isFailure(exit)).toBe(true) // the error was NOT caught})
it("the exact _tag string routes the error to the handler", () => { const program = /* @stub */ Effect.fail( new ValidationError("email", "bad input"), ).pipe( Effect.catchTag("ValidationError", () => Effect.succeed("handled")), ) /* @end */
expect(Effect.runSync(program)).toBe("handled")})// @end-beat