Errors and Runtime Validation

Types eliminate an entire class of bugs before the program runs — but they cannot eliminate errors that come from outside the program: user input, network responses, files, environment variables. This lesson covers the two halves of production error handling: what the checker can and cannot prove about failure, and how to build a verified boundary where untyped data becomes typed data.

What the type system cannot prevent

Strict mode removes type inconsistencies, but three failure sources remain fundamentally outside its reach. Naming them precisely is what separates systematic error handling from defensive clutter.

  • Data from outside. A JSON response parsed from the network is, at runtime, unverified data. JSON.parse returns any — the checker takes your word for whatever shape you claim.
  • Runtime failure conditions. Network timeouts, disk full, permission denied. These are not type errors; they are states of the world.
  • Logic errors on valid values. A discount of -999 is a perfectly valid number. The type system proves the value is a number, never that it is sensible.

The any-shaped hole in catch blocks

JavaScript's catch accepts anything a throw delivers — a string, a number, an Error. TypeScript models that honestly: by default (and with useUnknownInCatchVariables, part of strict) the caught value is unknown, and unknown must be narrowed before use.

Purpose of the example: show the correct reflex for every catch block: treat the caught value as opaque until proven otherwise.

try {
  await saveProfile(profile);
} catch (error) {                      // error: unknown under strict mode
  // Compiler error: 'error' is of type 'unknown'.
  // console.log(error.message);

  if (error instanceof Error) {        // narrow with instanceof first
    console.error(`Save failed: ${error.message}`);
  } else {
    // Something threw a non-Error — log it whole, never assume shape
    console.error("Save failed with non-Error throw:", error);
  }
}

What you should see: with the narrowing removed, the compiler rejects error.message — the same protection noImplicitAny gives variables, extended to the most chaotic value in the program. What we learn: throw "oops" is legal JavaScript, so catch (e: any) assumptions are lies waiting to happen; unknown plus an explicit narrow is the professional default.

Modeling failure in the type signature

Exceptions have a documentation problem: the signature fetchUser(id): Promise<User> says nothing about the three ways it can fail. Developers discover failure modes by reading the source — or by experiencing them in production. Type unions fix the documentation problem by putting failure in the signature.

Discriminated error unions

Purpose of the example: make every failure mode a value the caller must handle, using the discriminated-union skill from the types lesson.

type LoadUserResult =
  | { status: "ok"; user: User }             // success carries the payload
  | { status: "not-found"; id: string }      // each failure mode carries
  | { status: "network"; cause: Error }      // exactly the data needed
  | { status: "unauthorized" };              // to recover from it

async function loadUser(id: string): Promise<LoadUserResult> {
  try {
    const user = await api.getUser(id);
    return { status: "ok", user };
  } catch (cause) {
    if (cause instanceof NetworkError) {
      return { status: "network", cause };
    }
    throw cause;   // unclassified: rethrow, never swallow
  }
}

// The caller cannot read `.user` without proving status === "ok" first:
const result = await loadUser("u_42");
if (result.status === "ok") {
  render(result.user);        // narrowed: user is guaranteed here
} else {
  showError(result.status);   // every other case, handled explicitly
}

What you should see: the compiler refuses result.user outside the "ok" branch — the "cannot read property of undefined" crash is now a compile error. Exhaustiveness (the never check from the advanced-types lesson) makes adding a fifth failure mode break every caller that must react to it.

When to throw versus when to return

Both mechanisms are correct; the choice is about expectation:

SituationMechanismWhy
Programmer error (broken invariant, unreachable branch)throwShould never happen in a correct build; the stack trace is the point
Anticipated business outcomes (not found, invalid input)return unionCallers should handle it — put it in the signature so they must
Catastrophic environment failurethrowNo local recovery exists; let the top level log and crash

What we learn: the union style dominates in libraries and API layers where callers branch on outcomes; plain throws dominate inside application cores where failures cross layer boundaries. Mixing is normal — the rule is that anticipated outcomes are typed, not hidden.

Runtime validation at the boundary

Here is the pattern that catches most beginners: a type annotation is a claim, not a check. Cast an untyped API response with as User and the compiler believes you — nothing at runtime verifies a single field. This is why the boundary needs a validator: a function that checks the real data and reports the real type.

From any to typed: parse, don't cast

Purpose of the example: implement the "parse, don't validate" pattern with nothing but the standard library, so the mechanism is visible before introducing a validation library.

interface Article { id: string; title: string; views: number }

// Type guard: returns boolean, teaches the checker `value is Article`
function isArticle(value: unknown): value is Article {
  if (typeof value !== "object" || value === null) return false;
  const v = value as Record<string, unknown>;
  return typeof v.id === "string"
      && typeof v.title === "string"
      && typeof v.views === "number";
}

export function parseArticle(raw: unknown): Article | null {
  return isArticle(raw) ? raw : null;   // verified data, not asserted data
}

// At the network boundary:
const res = await fetch("/api/articles/1");
const body: unknown = await res.json();      // honest: we know nothing yet
const article = parseArticle(body);
if (!article) throw new Error("Malformed article from API");

What you should see: body is unknown — no field access compiles until the guard runs. After parseArticle succeeds, article is a fully typed Article that actually is one. Compare with await res.json() as Article: zero checks, silent undefined fields, and a crash hours later in unrelated code.

What we learn: the boundary is where trust begins. Hand-written guards are transparent and dependency-free; in larger projects, schema libraries (Zod, Valibot, and similar) generate both the runtime check and the TypeScript type from one schema definition — same principle, less duplication. The pattern's name is the rule: parse, don't validate — convert untrusted data into trusted types once, at the edge.

Narrowing user input: a form example

Purpose of the example: connect validation to state — a form field that is typed by what the user has done, not by what you hope.

type EmailField =
  | { state: "empty" }
  | { state: "invalid"; raw: string }
  | { state: "valid"; email: string };   // valid carries a verified value

function checkEmail(raw: string): EmailField {
  if (raw.trim() === "") return { state: "empty" };
  if (!/^\S+@\S+\.\S+$/.test(raw)) return { state: "invalid", raw };
  return { state: "valid", email: raw };
}

// Impossible to submit an unverified value — the types forbid it:
function submit(field: EmailField): void {
  if (field.state === "valid") {
    api.subscribe(field.email);   // only reachable with verified data
  }
}

What you should see: the invalid-state bug class ("user clicked submit with a half-typed email") is unrepresentable — submit has no way to reach an unverified string. What we learn: validation logic and state modeling are the same activity; modeling the states explicitly (as in the types lesson's state machines) makes invalid flows unrepresentable rather than merely discouraged.

Practice: build a typed boundary

Wrap a real API

Take any public JSON endpoint (or a local file). Write a parseX function returning a discriminated union of success and every plausible failure, with zero as casts between fetch and the returned value. What you should see: your handler code compiles without a single assertion — every branch is driven by a narrowing check. What we learn: a well-modeled boundary makes the rest of the program's types true, which is the entire return on investment of the type system.

Audit for swallowed errors

Search a project (yours or an open-source one) for empty catch blocks and catch (e) { return undefined }-style swallows. Rewrite two of them as error unions. What you should see: silent failure points become visible, handled branches — and the callers that ignored them become compiler errors you must resolve. That visibility is exactly what production incident reviews demand.