01·02Error Types

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.

04 - Error Types: Making Errors First-Class

Previous · Next

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: string
err._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.

> still compiles, but `catchTag` and `switch` exhaustiveness silently stop > working. `Data.TaggedError` does this for you; the manual form does not.

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")
> Tools that rename a class do not rename string literals, and a misspelled > tag inside `catchTag` is the kind of bug that ships silently. Prefer > `Data.TaggedError` (it removes the manual `as const`) and let your editor > autocomplete the catcher from the class name.

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 _tag exhaustively; 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.TaggedError is the canonical form. It plays directly with catchTag, Match.tag, and Effect.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-channel Effect signature these errors live in.
  • Next, 05-effect-basics: the constructors that lift these errors into E.
  • Later, 09-error-handling: catchTag, catchTags, mapError.
  • Related, 15-data: the wider Data module (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/.

index.test.ts
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 message
it("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 class
it("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 exercising
it("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 case
it("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
← PreviousResult TypesNext →Effect Basics