Skip to main content
CodeOath
← All posts

TypeScript63 min total · 21 parts

TypeScript Fundamentals: Types, Interfaces, Generics, and Why It Catches Bugs Before Runtime

Part 15 of 21 · ~3 min

unknown, any, and never

Three types that sound like they might sit on a spectrum from "loose" to "strict," and don't — each does something structurally different from the other two.

function handleWebhookLoose(body: any) {
  body.applicant.email.toLowerCase(); // compiles, no matter what body actually turns out to be
}

function handleWebhookSafe(body: unknown) {
  body.applicant; // Error — nothing is assumed about an unknown value until you prove it
  if (typeof body === "object" && body !== null && "applicant" in body) {
    // narrowed enough to proceed carefully — .applicant itself still needs its own check
  }
}

function rejectAlways(reason: string): never {
  throw new Error(`Cannot process: ${reason}`); // this function has no path that returns normally
}
What it meansDirect method access?
anyOpt this value out of checking, entirelyAllowed — nothing is verified
unknownSome value exists; nothing is assumed about its shape yetRefused until you narrow it
neverNo value could ever actually reach this pointN/A — there's nothing to call anything on

any spreads. The instant body above is typed any, body.applicant is any, body.applicant.email is any, and every function that ever receives any of those values inherits the same blind spot — one any at the boundary quietly disables checking for everything downstream of it. unknown exists specifically to stop that spread: it's the type for "a value is here, but nothing about its shape has been proven yet" — a webhook body, the result of JSON.parse, whatever a catch block hands you — and it makes narrowing mandatory before you can do anything with the value at all.

Speaking of catch blocks — here's a case that surprises almost everyone the first time they actually check it. JavaScript allows throw to raise absolutely anything, not just an Error, so it seems like the caught value should obviously be unknown by default. It isn't:

try {
  scoreApplicant(applicant, weights);
} catch (err) {
  err.message; // compiles fine under the default config — err is any here, not unknown
}

Getting unknown here requires opting in — either strict: true as a whole, or the specific flag bundled inside it, useUnknownInCatchVariables. Without that flag on, the one place in a codebase where you're most likely to be handling a genuinely unpredictable value defaults to the type that checks nothing at all.

never is different again — it's rarely something you type out by hand; it's usually a conclusion TypeScript reaches on its own, most usefully as the proof of exhaustiveness from the discriminated-union chapter, where landing on never inside a default branch is what confirms every real case got handled somewhere above it.

One more thing worth carrying into the next few chapters: none of the type system replaces checking data at the actual boundary where it enters your program. Every type discussed so far is erased before the program runs, which was the very first fact in this reference — asserting a webhook body as Applicant doesn't verify anything, it just tells the compiler to stop asking questions, and the vendor is free to change their payload shape without your build ever noticing. Data crossing a real boundary — a webhook, a form submission, localStorage — needs an actual runtime check, not a type-level promise. A schema library like Zod or Yup fills that gap in most real codebases: you describe the shape once, as a runtime schema, the library validates incoming data against it while the program is actually running, and the equivalent compile-time type gets generated from that same schema instead of maintained separately — which is what keeps the two descriptions from ever quietly disagreeing.