03 - Result Types: Errors as Values
Step 01 showed that throwing hides failure from the type system. The fix is to return failure as a value.
A plain TypeScript Result type makes the idea concrete; Effect<A, E, R> extends it with laziness, composition, and dependency tracking. This step is about the type, the constructors and runners follow in step 05.
What you’ll work through
index.test.ts contains five beats. The first introduces a hand-rolled Result union; the next four lift the same idea into Effect’s three-channel signature (A, E, R), including a typed ParseError and an error union.
Most beats have one or two stubs that ask you to construct the right Effect.succeed or Effect.fail value.
By the end you should be able to read any Effect<A, E, R> signature and name what success, failure, and dependency each channel represents.
Mental Model
A Result is a tagged union: success or failure, never both. The compiler narrows the type inside each branch, so the value channel is only reachable after confirming success.
Result<A, E>┌───────────────────────────────────┐│ { ok: true; value: A } │ ← success│ { ok: false; error: E } │ ← failure└───────────────────────────────────┘
Effect<A, E, R>┌───────────────────────────────────┐│ A success channel ││ E error channel ││ R requirements (deps, step 21) │└───────────────────────────────────┘E is part of the return type. Every caller can see, and the compiler enforces, that failure must be handled or propagated.
Errors as values with a plain Result
The smallest possible version, no library required.
type Result<A, E> = { ok: true; value: A } | { ok: false; error: E }
function parseAge(input: string): Result<number, string> { const n = parseInt(input) if (isNaN(n)) return { ok: false, error: "Not a number" } if (n < 0) return { ok: false, error: "Negative age" } return { ok: true, value: n }}
const result = parseAge("25")if (result.ok) { result.value // number} else { result.error // string}The discriminant ok lets TypeScript narrow inside each branch. The value channel is only reachable after the success check; the error channel is only reachable after the failure check.
No casts, no exceptions.
When to Use
Local code and toy examples where you do not need composition, dependency injection, retry, or async. Once any of those enter the picture, reach for Effect.
Your turn check the `ok` discriminant first, then read `value` or `error` from inside the narrowed branch.
Lift values into Effect’s A and E channels
The Effect type carries the same idea with three channels instead of two.
A, the success value the effect produces on completion.E, the typed expected error. Recoverable, narrowed by handlers (step 09).R, the services the effect requires from context. Defaultnever(no dependencies).
import { Effect } from "effect"
// ┌─── Effect<number, never, never>// ▼const always42 = Effect.succeed(42)
// ┌─── Effect<never, string, never>// ▼const alwaysFail = Effect.fail("something went wrong")Read the types as: Effect<User, NotFound> succeeds with User or fails with NotFound. Effect<void, never> always succeeds.
Effect<never, FatalError> always fails.
Notice.
neverin the success position is not “no error” — it means the Effect cannot succeed. The infallible shape isEffect<A, never>, withneverin the error position. The two channels read symmetrically.
Your turn lift `42` into success and `"oops"` into failure so each program produces the expected `Exit` — the first asserts `Exit.isSuccess`, the second `Exit.isFailure`.
Construction does not execute
An Effect is an immutable description of a computation. Constructing one performs no work; a runner is required.
This separation is what makes retries, timeouts, dependency injection, and tracing possible — they operate on the description, not on an execution that has already happened.
let called = falseconst program = Effect.sync(() => { called = true return 42})
// called === false here — building `program` ran nothing.Effect.runSync(program)// called === true now — the runner walked the description.Rule of thumb. If a variable holds an
Effect.Effect<...>, no work has happened yet. The work happens at the runner call site. Step 02 grounded this inEffect.sync; the same is true for every constructor you’ll meet.
Your turn predict `called` before `runSync` (the first stub) and after (the second stub). The expected values are `false` and `true` — proof that the runner is what flips the bit.
A Typed Error
Strings are valid in E, but you lose narrowing. Real domain code uses tagged classes (step 05).
Here is the shape a parser ends up with.
import { Data, Effect } from "effect"
class ParseError extends Data.TaggedError("ParseError")<{ readonly input: string readonly reason: string}> {}
// ┌─── Effect<number, ParseError, never>// ▼const parseAge = (input: string): Effect.Effect<number, ParseError> => { const n = parseInt(input) if (isNaN(n)) return Effect.fail(new ParseError({ input, reason: "not a number" })) if (n < 0) return Effect.fail(new ParseError({ input, reason: "negative age" })) return Effect.succeed(n)}The signature is the specification: success is number, failure is ParseError. Nothing else can happen in the typed channels.
Your turn construct `ParseError` with the failing `input` and a `reason` for each of the two rejection branches.
Error Unions
When a function can fail in more than one way, list every error in E.
// ┌─── Effect<number, "NotANumber" | "NotPositive", never>// ▼const parsePositive = ( input: string,): Effect.Effect<number, "NotANumber" | "NotPositive"> => { const n = parseInt(input) if (isNaN(n)) return Effect.fail("NotANumber" as const) if (n <= 0) return Effect.fail("NotPositive" as const) return Effect.succeed(n)}The compiler tracks the union through every transformation. Recovery operators (catchTag, catch, catchTags — step 09) remove variants one at a time and the type narrows accordingly.
If a caller forgets to handle a variant, the union still names it in E — the signature can’t lie.
Why
as const. Withoutas const,Effect.fail("NotANumber")widens toEffect<never, string>and the literal-string union evaporates. Tagged classes (step 04) avoid this entirely.
Your turn return `Effect.fail("NotANumber" as const)` for the NaN branch and `Effect.fail("NotPositive" as const)` for the non-positive branch, so the `E` channel ends up as the exact union the signature promises.
Common Pitfalls
1, Effect<never, E> is not “no error”. never in the success position means the effect cannot succeed. The “infallible” shape is Effect<A, never>, never in the error position.
2, Construction does not execute. Effect.succeed(42) is a value, not a result. A runner (step 05) is required.
3, string in E defeats narrowing. A string error cannot be discriminated. Use a tagged class (Data.TaggedError, step 04) when more than one failure exists.
4, R = never means “no dependencies”, not “no errors”. The three channels are independent.
`value` or `error`. Confirm both branches type-check without casts. - [ ] Complete `succeed-and-fail`: lift `42` and `"oops"` into the success and error channels. The two assertions should land on the correct `Exit` shape. - [ ] Read `construction-is-lazy` and note where the side effect fires — only after the runner is invoked. - [ ] Complete `typed-tagged-error`: construct `ParseError` with `{ input, reason }` for each rejection branch. - [ ] Complete `error-unions`: pick the right tag for each rejection so the union narrows correctly. - [ ] In one sentence, state what `Effect
See Also
- Previous,
02-description-vs-execution: why an Effect is a value, not work in flight. - Next,
04-error-types:Data.TaggedErrorand discriminated unions. - Later,
05-effect-basics: the constructors and runners. - Later,
09-error-handling: narrowingEwithcatchTagand friends.
Run the tests: pnpm test 03-result-types
The full test suite
The index.test.ts for this lesson, verbatim from steps/03-result-types/.
import { it, expect } from "vite-plus/test"import { Effect, Exit, Data } from "effect"
// =============================================================================// 02 - Result Types: Errors as Values// =============================================================================//// A Result is a tagged union of success or failure. Effect<A, E, R> extends the// idea with laziness, composition, and dependency tracking. This file covers// the type only — constructors and runners are exercised in detail in step 03.
type Result<A, E> = { ok: true; value: A } | { ok: false; error: E }
function parseAge(input: string): Result<number, string> { const n = parseInt(input) if (isNaN(n)) return { ok: false, error: "Not a number" } if (n < 0) return { ok: false, error: "Negative age" } return { ok: true, value: n }}
// @beat// id: plain-result// shape: multi-todo// title: Errors as values with a plain Result// teaches: A tagged union with an `ok` discriminant narrows to either the value or the error inside each branch.// hint: check `result.ok` first, then read `result.value` or `result.error`it("narrows to the value when ok is true", () => { const result = parseAge("25") expect(result.ok).toBe(true) if (/* @stub */ result.ok /* @end */) { expect(result.value).toBe(25) }})
it("narrows to the error when ok is false", () => { const result = parseAge("banana") expect(result.ok).toBe(false) if (/* @stub */ !result.ok /* @end */) { expect(result.error).toBe("Not a number") }})// @end-beat
// @beat// id: succeed-and-fail// shape: multi-todo// title: Lift values into Effect's A and E channels// teaches: Effect.succeed lifts a value into A; Effect.fail lifts a value into E.// hint: use Effect.succeed for the value and Effect.fail for the errorit("Effect.succeed produces Effect<A, never, never>", () => { // ┌─── Effect<number, never, never> // ▼ const program = /* @stub */ Effect.succeed(42) /* @end */ expect(Effect.runSync(program)).toBe(42)})
it("Effect.fail produces Effect<never, E, never>", () => { // ┌─── Effect<never, string, never> // ▼ const program = /* @stub */ Effect.fail("oops") /* @end */ const exit = Effect.runSyncExit(program) expect(Exit.isFailure(exit)).toBe(true)})// @end-beat
// @beat// id: construction-is-lazy// shape: multi-todo// title: Construction does not execute// teaches: Building an Effect performs no work; the description runs only when handed to a runner.// hint: predict `called` before runSync, then after — the runner flips itit("construction does not execute the computation", () => { let called = false const program = Effect.sync(() => { called = true return 42 })
// Predict `called` before any runner has touched the program. expect(called).toBe(/* @stub */ false /* @end */) Effect.runSync(program) expect(called).toBe(/* @stub */ true /* @end */)})// @end-beat
// @beat// id: typed-tagged-error// shape: multi-todo// title: Put a structured error in E// teaches: A Data.TaggedError carries typed fields into the E channel so callers can inspect what went wrong.// hint: construct ParseError with { input, reason } and place it inside Effect.failit("succeeds on the happy path", () => { class ParseError extends Data.TaggedError("ParseError")<{ readonly input: string readonly reason: string }> {}
// ┌─── Effect<number, ParseError, never> // ▼ const parseAgeE = (input: string): Effect.Effect<number, ParseError> => { const n = parseInt(input) if (isNaN(n)) return /* @stub */ Effect.fail(new ParseError({ input, reason: "not a number" })) /* @end */ if (n < 0) return /* @stub */ Effect.fail(new ParseError({ input, reason: "negative age" })) /* @end */ return Effect.succeed(n) }
expect(Effect.runSync(parseAgeE("25"))).toBe(25)})
it("places a typed error in the E channel", () => { class ParseError extends Data.TaggedError("ParseError")<{ readonly input: string readonly reason: string }> {}
const parseAgeE = (input: string): Effect.Effect<number, ParseError> => { const n = parseInt(input) if (isNaN(n)) return Effect.fail(new ParseError({ input, reason: "not a number" })) if (n < 0) return Effect.fail(new ParseError({ input, reason: "negative age" })) return Effect.succeed(n) }
const exit = Effect.runSyncExit(parseAgeE("banana")) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit) && exit.cause.reasons[0]?._tag === "Fail") { expect(exit.cause.reasons[0].error._tag).toBe("ParseError") expect(exit.cause.reasons[0].error.reason).toBe("not a number") }})// @end-beat
// @beat// id: error-unions// shape: one-todo// title: List every failure mode in E// teaches: A function with multiple failure modes lists each one in the E channel as a union.// hint: return Effect.fail with two distinct literal tags so the union has both membersit("tracks every failure mode in the union", () => { // ┌─── Effect<number, "NotANumber" | "NotPositive", never> // ▼ const parsePositive = (input: string): Effect.Effect<number, "NotANumber" | "NotPositive"> => { const n = parseInt(input) if (isNaN(n)) return /* @stub */ Effect.fail("NotANumber" as const) /* @end */ if (n <= 0) return /* @stub */ Effect.fail("NotPositive" as const) /* @end */ return Effect.succeed(n) }
expect(Effect.runSync(parsePositive("5"))).toBe(5)
const e1 = Effect.runSyncExit(parsePositive("banana")) if (Exit.isFailure(e1) && e1.cause.reasons[0]?._tag === "Fail") { expect(e1.cause.reasons[0].error).toBe("NotANumber") }
const e2 = Effect.runSyncExit(parsePositive("-3")) if (Exit.isFailure(e2) && e2.cause.reasons[0]?._tag === "Fail") { expect(e2.cause.reasons[0].error).toBe("NotPositive") }})// @end-beat