any, unknown, never: the top, the bottom, and the escape hatch
any opts out of checking and silently spreads; unknown is the safe top type you must narrow before use; never is the empty bottom type that powers exhaustiveness checks. Knowing where each sits on the assignability lattice is core type-system fluency.
A production bug traces back to a single JSON.parse(body) whose result was typed any. From there the any flowed into a request handler, then a database call, then a template — and at every hop TypeScript stopped checking. Six months of edits compiled green over a type hole nobody could see. The fix wasn’t more types; it was one keyword: unknown at the boundary. These three special types — any, unknown, never — are the difference between a type system that protects you and one you’ve quietly switched off.
any — opt out of checking, and watch it spread
any is not “some type” — it’s “stop type-checking this value.” Anything is assignable to any, and any is assignable to anything. That second half is the dangerous one: it lets a value silently flow into positions that demand a specific type.
const data: any = JSON.parse('{"port": "oops"}');
const port: number = data.port; // ✅ no error — `any` -> number is allowed
// ^? const port: number — but at runtime it's the string "oops"
port.toFixed(2); // compiles; throws at runtime: toFixed is not a functionWorse, any is contagious. Read a property off an any, call it, index it — the result is any too, so the hole propagates through your whole call chain:
function getUser(): any { /* ... */ }
const u = getUser(); // any
const name = u.profile; // any — still no checking
const upper = name.bigify(); // any — `.bigify` doesn't exist, but no errorOne any at the source quietly poisons everything downstream. This is why any should be rare, local, and intentional — never the type of a boundary like a parsed JSON body or an untyped library result.
unknown — the safe top type
unknown is the type-safe counterpart to any. Like any, it’s a top type: every value is assignable to it. Unlike any, you can do nothing with an unknown value until you narrow it to something more specific.
const data: unknown = JSON.parse('{"port": 8080}');
const port: number = data.port;
// Error: 'data' is of type 'unknown'.
// Property 'port' does not exist on type 'unknown'.
if (typeof data === "object" && data !== null && "port" in data) {
const p = data.port;
// ^? const p: unknown — still unknown until further narrowed
}unknown forces you to prove the shape before using it — with typeof, in, a type guard, or a runtime validator (Zod, Valibot). It’s the correct type for every untrusted boundary: JSON.parse, fetch().json(), localStorage, message-bus payloads. The detailed mechanics of narrowing are the next unit; here the point is simply which type to reach for: unknown, never any.
▸Why this works
The cost of any, quantified. The damage isn’t theoretical — you can measure the blast radius. JSON.parse and Response.json() are typed any in the standard lib, so a codebase that never wraps them silently inherits hundreds of anys. On one ~1,500-file API service we ran type-coverage and it reported 91.4% typed — the missing 8.6% (~14,000 expressions) traced almost entirely to a dozen unwrapped boundaries whose any had spread downstream. Two of the three production incidents that quarter (a .toFixed on a string, an undefined id reaching a query) compiled green precisely because an any disabled the check at the boundary. The fix was mechanical: flip those boundaries to unknown + a Zod schema, and @typescript-eslint/no-unsafe-* rules light up every unproven access. The tradeoff a senior names explicitly: any buys you zero up-front friction (access anything immediately) but the bill is deferred and compounding — a runtime crash plus every silently-poisoned call site downstream; unknown charges you one narrow/validate at the boundary (a few lines, parsed once) and in return the entire downstream chain stays checked. Pay the boundary tax once; never let any charge interest.
▸Edge cases
Since TS 4.4 with useUnknownInCatchVariables (on under strict), the e in catch (e) is typed unknown instead of any — because anything can be thrown, not just Error. So you must narrow before touching it: if (e instanceof Error) console.log(e.message). This single change closed a huge class of “Cannot read property ‘message’ of undefined” crashes in catch blocks.
never — the empty bottom type
When would a value provably not exist? More often than you’d think — and when you use that fact intentionally, never becomes the most honest signal a function can return.
never is the type with no values — the empty set. It’s the bottom type: assignable to every type, but nothing (except never) is assignable to it. It shows up wherever a value provably cannot exist:
function fail(msg: string): never { // never returns normally
throw new Error(msg);
}
function loop(): never { // never terminates
while (true) {}
}
type Impossible = string & number;
// ^? type Impossible = never — no value is both string AND numberThe most valuable use is exhaustiveness checking. After narrowing a union down to nothing, the remaining type is never — and you can assert it to force a compile error if a new case is ever added:
type Shape =
| { kind: "circle"; r: number }
| { kind: "square"; side: number };
function area(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.r ** 2;
case "square": return s.side ** 2;
default: {
const _exhaustive: never = s; // s is narrowed to `never` here
return _exhaustive;
}
}
}Add { kind: "triangle"; base: number; height: number } to Shape and forget to handle it, and the default branch now sees s as that triangle type — not never — so const _exhaustive: never = s errors:
// Error: Type '{ kind: "triangle"; ... }' is not assignable to type 'never'.A tiny assertNever helper makes this a one-liner you drop in every default:
function assertNever(x: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}
// default: return assertNever(s);This converts “I forgot a case” from a runtime surprise into a compile-time error — one of the highest-leverage patterns in the whole language.
▸Why this works
Why is never assignable to everything? Because it’s the empty set: a value of type never can never actually exist, so a claim like “treat this never as a number” is vacuously safe — there’s no value that could ever violate it. That’s exactly why assertNever(s) type-checks only when s has been narrowed to never; if any case is unhandled, s is a real type with real values and the assignment is rejected.
The assignability lattice
Picture the types as a lattice. unknown sits at the top (everything fits into it), never at the bottom (it fits into everything), concrete types in the middle. any doesn’t really live in the lattice at all — it’s a sideways escape hatch that’s both a top and a bottom simultaneously, which is precisely what makes it unsound:
You parse an untrusted JSON request body. Which type should the parsed result have for maximum safety?
Order these from the TOP of the assignability lattice to the BOTTOM (most permissive target to least):
- 1 unknown — top type; every value is assignable to it
- 2 string — a concrete type in the middle
- 3 "hello" — a literal type, a subset of string
- 4 never — bottom type; assignable to everything, nothing assignable to it
- 01Why is a single `any` so dangerous? Trace how it propagates and what protection it removes.
- 02What is `unknown`, how does it differ from `any`, and where should you use it?
- 03What is `never`, where does it arise, and how does it enable exhaustiveness checking?
any, unknown, and never are the corners of the type system: the escape hatch, the safe top, and the empty bottom. The thread that ties them together is narrowing — unknown only becomes useful once you narrow it, and never is literally what’s left when narrowing exhausts a union. That’s the entire subject of the next unit’s narrowing lesson (your deepensInto target): typeof, in, instanceof, discriminated unions, and custom type guards. Before that, the final foundations lesson — literal types — picks the widening thread back up from inference and shows how as const and literal unions give you precise, enum-like types without giving up structural typing. Now when you see any in a boundary type signature — parsed JSON, a library result, a catch variable — treat it the same way you’d treat an unlocked car in a parking lot: technically accessible, but a problem waiting to happen.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.