01 - The Problem: Why try/catch Falls Short
Errors are part of every program. What matters is whether the language can model them.
With try/catch, every function that throws lies about its return type and every catch clause receives unknown.
This step makes the three concrete consequences observable and motivates the typed alternative that the rest of the tutorial builds on.
What you’ll work through
index.test.ts contains four short beats. The first three are read-only — run them and observe how try/catch fails the type system in three distinct ways.
The final beat is interactive: three stubbed Effect calls you’ll replace to see typed errors in action.
By the end you should be able to answer, in one sentence, why a throwing function’s signature is a lie.
Mental Model
A throwing function communicates failure through a hidden side-channel that the type system does not track. A function that returns number may, at any point, transfer control to a distant catch, and the compiler cannot warn you.
try/catch───────────────────────────────────────────── parseAge("abc") │ ▼ declared: string -> number │ │ ╔══════════════════════════╗ └──►║ throw new Error(...) ║ ← untracked ╚══════════════════════════╝ │ ▼ discovered at runtime
Effect───────────────────────────────────────────── parseAge("abc") │ ▼ declared: string -> Effect<number, ParseError> │ ├── success channel ──► number └── error channel ──► ParseError ← visible to the compilerThe E channel is the entire point: failure becomes part of the signature.
The signature lies
// Declared as `number`. May throw at any point.function parseAge(input: string): number { const n = parseInt(input) if (isNaN(n)) throw new Error("Not a number") if (n < 0) throw new Error("Negative age") return n}
const age: number = parseAge("banana") // compiles. blows up.TypeScript has no throws clause. The return type carries no information about failure, and callers receive no warning.
Every throwing function widens the runtime surface the type system pretends does not exist.
Notice. The compiler accepted
const age: number = parseAge("banana")without a warning. The runtime throws. The signature and the behaviour disagree, and the compiler has no way to tell you.
Your turn run the tests and confirm both throws fire from a function declared `: number`. You should see two failures inside the success path.
Composition is nesting
Each additional fallible step adds one level of indentation and one error rewrap. The shape of the code grows with the number of error sites, not the number of business steps.
async function createUser(nameInput: string, ageInput: string) { try { const name = validateName(nameInput) try { const age = parseAge(ageInput) return { name, age } } catch (ageError) { throw new Error(`Age validation failed: ${(ageError as Error).message}`) } } catch (nameError) { throw new Error(`Name validation failed: ${(nameError as Error).message}`) }}Three steps produce a pyramid. Recovery decisions are inverted (innermost first).
The logical sequence, validate name, parse age, return both, is buried under control flow.
Your turn run the tests and see how each added rule deepens the pyramid.
No exhaustiveness
In strict TypeScript a catch parameter is unknown. Nothing tells you which throws are possible.
Every recovery decision starts with a guess.
try { const result = someLibraryFunction()} catch (e) { // `e` is unknown. Casting is a bet. const message = (e as Error).message}There is no compiler check for “have I handled every failure mode of this function”. The documentation, if it exists, is the only specification.
> hand-written `catch` clauses. The test in this beat throws a plain > `string` — the check returns `false`, and `message` is `undefined`.Your turn confirm that a thrown `string` slips past `e instanceof Error` at runtime. The assertion checks `typeof e`, not the cast.
The Effect Alternative (preview)
Effect replaces the hidden side-channel with an explicit type parameter.
import { Effect } from "effect"
// ┌─── Effect<number, ParseError, never>// ▼declare const parseAge: (input: string) => Effect.Effect<number, ParseError>number, the success value.ParseError, the typed expected error.never, no required dependencies (covered in step 21).
Callers must handle the error (or propagate it), and the compiler enforces both. Step 03 introduces the type.
Step 05 introduces the constructors.
Your turn replace the three stubs with `Effect.succeed` / `Effect.fail` so the preview parser returns its result as a value. The first assertion runs the happy path; the second asserts the failure surfaces in `Exit`, not as a throw.
Common Pitfalls
1, Expecting TypeScript to track throws. It does not. throws is not part of the type system.
A function declared : number can throw anything.
2, Assuming Error covers all throws. Code can throw strings, numbers, plain objects, or DOMException. e instanceof Error is a runtime check, not a guarantee.
3, Confusing nested try/catch with composition. Nesting hides the business sequence inside error-handling scaffolding. More steps means more pyramid.
— they document the three failure modes of `try/catch`. - [ ] Open `index.test.ts` and find the `effect-preview` beat. Replace the three `/* @stub */` blocks with the appropriate `Effect.succeed` and `Effect.fail` calls. - [ ] Re-run the tests. The happy-path assertion should return `25`; the failure assertion should report `Exit.isFailure === true`. - [ ] In one sentence, write down why TypeScript's `throws`-less type system cannot warn you about the failure surface. (This becomes the answer step 03 builds on.)
See Also
- Next,
02-description-vs-execution: why creating an Effect does nothing. - Later,
03-result-types: errors as values, theEffect<A, E, R>type. - Later,
05-effect-basics: constructors and runners. - Later,
09-error-handling: recovering withcatchTag,catch,mapError.
Run the tests: pnpm test 01-the-problem
The full test suite
The index.test.ts for this lesson, verbatim from steps/01-the-problem/.
import { it, expect } from "vite-plus/test"import { Effect, Exit } from "effect"
// =============================================================================// 01 - The Problem: Why try/catch Falls Short// =============================================================================//// Three failure modes of try/catch made executable:// 1. The signature lies — return type says `number`, function throws.// 2. Composition is nesting — every step adds indentation and an error rewrap.// 3. No exhaustiveness — `catch` receives `unknown`.// The closing block previews the Effect alternative: errors as values in E.
function parseAge(input: string): number { const n = parseInt(input) if (isNaN(n)) throw new Error("Not a number") if (n < 0) throw new Error("Negative age") return n}
function validateName(name: string): string { if (name.length === 0) throw new Error("Name cannot be empty") if (name.length > 100) throw new Error("Name too long") return name}
async function createUser(nameInput: string, ageInput: string) { try { const name = validateName(nameInput) try { const age = parseAge(ageInput) return { name, age } } catch (ageError) { throw new Error(`Age validation failed: ${(ageError as Error).message}`) } } catch (nameError) { throw new Error(`Name validation failed: ${(nameError as Error).message}`) }}
// @beat// id: signature-lies// shape: multi-todo// title: The signature lies// teaches: A throwing function's return type hides every failure mode from the compiler.// hint: the signature says `number` and `string` — predict what each happy-path call returnsit("declares `number` and throws on bad input", () => { // Predict the parsed number for input "25". expect(parseAge("25")).toBe(/* @stub */ 25 /* @end */) expect(() => parseAge("banana")).toThrow("Not a number") expect(() => parseAge("-5")).toThrow("Negative age")})
it("hides failures in every throwing function", () => { // Predict what validateName returns for a valid name. expect(validateName("Alice")).toBe(/* @stub */ "Alice" /* @end */) expect(() => validateName("")).toThrow("Name cannot be empty") expect(() => validateName("x".repeat(101))).toThrow("Name too long")})// @end-beat
// @beat// id: nested-composition// shape: multi-todo// title: Composition is nesting// teaches: Each fallible step adds one try/catch level and one error rewrap, so shape grows with errors not logic.// hint: predict the shape returned from the happy path — both fields you pass in surviveit("requires one try/catch per fallible step", async () => { const result = await createUser("Alice", "25") // Predict the object shape returned on the happy path. expect(result).toEqual(/* @stub */ { name: "Alice", age: 25 } /* @end */)
await expect(createUser("", "25")).rejects.toThrow("Name validation failed") await expect(createUser("Alice", "banana")).rejects.toThrow("Age validation failed")})
it("rewraps the error at every level", async () => { async function createAdult(nameInput: string, ageInput: string) { try { const name = validateName(nameInput) try { const age = parseAge(ageInput) try { if (age < 18) throw new Error("Must be an adult") return { name, age } } catch (adultError) { throw new Error(`Adult check failed: ${(adultError as Error).message}`) } } catch (ageError) { throw new Error(`Age validation failed: ${(ageError as Error).message}`) } } catch (nameError) { throw new Error(`Name validation failed: ${(nameError as Error).message}`) } }
const ok = await createAdult("Alice", "25") // Predict the object the happy path returns. expect(ok).toEqual(/* @stub */ { name: "Alice", age: 25 } /* @end */)
await expect(createAdult("Alice", "15")).rejects.toThrow( "Name validation failed: Age validation failed: Adult check failed: Must be an adult", )})// @end-beat
// @beat// id: catch-is-unknown// shape: one-todo// title: catch receives unknown// teaches: A `catch` parameter is `unknown`; every recovery decision starts with a runtime guess about the error shape.// hint: the throw is a bare string literal — what does `typeof` report for that value?it("forces a runtime guess at the error shape", () => { const throwsAString = () => { throw "string, not Error" }
let kind: string try { throwsAString() kind = "no throw" } catch (e) { kind = e instanceof Error ? "Error" : typeof e }
// Predict the `typeof` of the thrown value when it isn't an Error. expect(kind).toBe(/* @stub */ "string" /* @end */)})// @end-beat
// @beat// id: effect-preview// shape: multi-todo// title: Preview — errors as values in E// teaches: Returning Effect.fail puts the failure in a typed E channel the compiler can see and require callers to handle.// hint: use Effect.succeed for the happy path and Effect.fail for the rejected inputsit("declares failure in the type", async () => { function parseAgeEffect(input: string): Effect.Effect<number, string> { const n = parseInt(input) if (isNaN(n)) return /* @stub */ Effect.fail("Not a number") /* @end */ if (n < 0) return /* @stub */ Effect.fail("Negative age") /* @end */ return /* @stub */ Effect.succeed(n) /* @end */ }
const value = await Effect.runPromise(parseAgeEffect("25")) expect(value).toBe(25)})
it("places the error in the E channel", async () => { function parseAgeEffect(input: string): Effect.Effect<number, string> { const n = parseInt(input) if (isNaN(n)) return Effect.fail("Not a number") if (n < 0) return Effect.fail("Negative age") return Effect.succeed(n) }
const exit = await Effect.runPromiseExit(parseAgeEffect("banana")) expect(Exit.isFailure(exit)).toBe(true)})// @end-beat