How it works
The three form the extremes of assignability. unknown sits above every type (TS 3.0 release notes: anything is assignable to it; it's assignable only to unknown and any). never sits below every type (handbook: assignable to every type; nothing is assignable to it except never). any is deliberately outside the lattice: it is allowed in both directions, except into never.
Narrowing is how unknown becomes useful. typeof x === "string" narrows to string; x instanceof Error to Error; a user-defined guard (x is Foo) to Foo. The never type arises from narrowing too: when every constituent of a union has been ruled out, what's left is never. That is why assigning the leftover to a never variable proves exhaustiveness, and why adding a member to the union breaks the build at that line.
Edge cases
- Absorption.
type T1 = unknown | string is unknown; type T3 = unknown & string is string, so const t: T3 = 1 fails with Type 'number' is not assignable to type 'string'; any & string is any, so 1 is accepted. All verified on 7.0.2.
- Inferred return type.
export function declared() { throw ... } is emitted as (): void, but export const expr = () => { throw ... } as () => never. A call to a function with an explicit : never return type ends control flow, so function g(n: number): number { if (n > 0) return n; fail("neg"); } compiles. The same with an unannotated const fail = () => { throw ... } fails with TS2366: Function lacks ending return statement and return type does not include 'undefined'.
catch. Under strict, catch (e) gives unknown: e.message fails with 'e' is of type 'unknown'. Turn the flag off and it's any.
- Implicit
any. An unannotated parameter with no contextual type is any unless noImplicitAny reports it (Parameter 's' implicitly has an 'any' type, per the TSConfig reference).
Real-world scenario
Reading a config file. JSON.parse returns any; accept it as unknown immediately and narrow before use, so one validation point guards every later access:
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readPort(raw: unknown): number {
if (!isRecord(raw) || typeof raw.port !== "number") {
throw new TypeError("config.port must be a number");
}
return raw.port;
}
console.log(readPort(JSON.parse('{"port":8080}'))); // → 8080
try {
readPort(JSON.parse('{"port":"80"}'));
} catch (error) {
console.log(error instanceof Error ? error.message : error); // → config.port must be a number
}
Type-checked with --strict and run on Node 24.19 (node file.mts), the output is as shown. For larger shapes, a schema validator replaces the hand-written guard; the principle (parse into unknown, validate once, then trust) stays the same.
Trade-offs
any costs nothing to write and defeats the compiler downstream; unknown costs a guard at the boundary and keeps every later use checked. Use any when a precise type is impractical and the blast radius is local (migrating a file, a test double), and comment why. The never default branch costs one line and turns "forgot a case" from a runtime bug into a compile error, but it only works if the switch subject is a union that narrows by that check.