Most TypeScript types describe a set of values: string is every string,
boolean is true and false. Three special types sit at the extremes,
and knowing which one to reach for is one of the clearest signs of
TypeScript that's actually doing its job.
any switches the checker off. Every property exists, every call is allowed, and whatever you assign it to becomes unchecked too. The mistake survives until it crashes at runtime.
any: type checking switched off
let data: any = JSON.parse(input);
data.user.name.toUpperCase(); // compiles — even if there is no user
any tells the checker to stop checking. Every property access, call and
assignment is allowed. Worse, it spreads: anything you assign an any to
becomes unchecked too.
const name: string = data.whatever; // fine to the compiler; maybe undefined at runtime
It exists for good reasons — migrating JavaScript gradually, describing
truly dynamic code — but every any is a hole in the safety net.
unknown: the safe version of any
let data: unknown = JSON.parse(input);
data.user; // error: 'data' is of type 'unknown'.
unknown accepts any value, just like any. The difference is on the way
out: you can't use it until you've proved what it is.
if (typeof data === 'object' && data !== null && 'user' in data) {
data.user; // allowed — and itself unknown until you check it too
}
Each check narrows the type, so the
checks you'd write anyway to handle bad input are exactly what unlock the
value. That makes unknown the right type for anything that comes from
outside your program:
JSON.parseresults and API responsescatch (err)— what was thrown could be anythinglocalStorage,postMessagedata, user input- Function parameters that genuinely accept anything
try {
risky();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
}
With "strict": true, catch variables are unknown by default
(useUnknownInCatchVariables).
never: the type with no values
never is the empty set — no value has type never. It shows up in two
places.
Functions that never return — because they always throw, or loop forever:
function fail(message: string): never {
throw new Error(message);
}
const port: number = process.env.PORT ? Number(process.env.PORT) : fail('PORT is required');
Since fail never produces a value, its result is assignable to anything,
so the expression above types cleanly as number.
Code that can't be reached. When narrowing has ruled out every
possibility, what's left is never. That's the basis of the exhaustive
check in discriminated unions:
function assertNever(x: never): never {
throw new Error(`Unhandled: ${JSON.stringify(x)}`);
}
Pass it a value in a switch's default branch, and adding a new case to
the union turns every forgotten switch into a compile error.
Quick reference
| any | unknown | never | |
|---|---|---|---|
| Accepts | Every value | Every value | No value |
| Can use it without checks | Yes — unsafe | No | N/A (it can't exist) |
| Assignable to other types | Yes | Only to unknown and any | Yes, to everything |
| Typical use | Escape hatch, gradual migration | Outside data, catch errors | Throwing functions, exhaustiveness |
Habits worth adopting
- When you're about to type
any, tryunknownfirst. If the code then needs checks to compile, those checks were missing bugs. - Turn on the
noImplicitAnyrule (part ofstrict) soanynever appears without you writing it. - When you must use
any, keep it local and leave a comment saying why. - Let
nevershow up in error messages as a signal: "type X is not assignable to never" usually means a case you forgot to handle.
The short version: any trusts everything, unknown trusts nothing until
it's proven, and never is what's left when there's nothing left.