Lesson 45·M5 Type-Level Programming
unknown vs any vs never
OpenLearning Objectives
By the end of this lesson you should be able to:
- State the difference between
any,unknownandneverin one sentence each - Explain why
unknownis the safe version ofany - Say when you would actually use
never - Narrow
unknowncorrectly (and explain whytypeofnarrowing matters) - Spot the
neverin a conditional type and explain where it comes from
One-line Definition
any disables type checking, unknown is a safe top type that forces you to narrow, and
never is the bottom type that no value can inhabit.
Mental Model
Picture a type hierarchy:
any— the "no checks" escape hatch. It accepts everything and gives you nothing back in return: no errors, no narrowing, no safety.unknown— the "we don't know yet" box. Everything fits in, but you can't take anything out until you prove what it is. It'sanywith a seatbelt.never— the empty set. Not "no value yet" likeundefined— a type with zero possible values. You can never have one; you can only infer one.
any — accepts everything, checks nothing
│
┌────────────┴────────────┐
assignable to any assignable to any unknown — accepts everything,
│ │ but nothing can be used
▼ ▼ until narrowed
string number
└────────────┬────────────┘
▼
never — the bottom: nothing is assignable TO itVisual Flow
assignable FROM assignable TO
┌─────────────────────┐ ┌─────────────────────┐
│ any → any type │ │ any type → any │ ✅ always
│ any → unknown │ │ any type → unknown │ ✅ always
│ any → string │ │ string → any │ ✅ always
│ unknown → string │ │ string → unknown │ ✅ always
│ string → unknown │ │ unknown → string │ ❌ needs narrowing
│ never → string │ │ string → never │ ❌ never
└─────────────────────┘ └─────────────────────┘
Everything is assignable to unknown; only never (and any) are assignable to
never's siblings freely; only any flows both ways everywhere.
How It Works
any — checks are off
function getAny(): any {
return JSON.parse('{"x": 1}');
}
const value = getAny();
value.foo.bar.baz; // no error — `any` walks right past this
console.log(value + 1); // no error either
Output:
undefined + 1 → NaN
Pitfall
value.foo.bar.baz compiled cleanly. Every one of those property accesses would throw at
runtime — but any suppressed the errors, so they land on the user instead of your
terminal. This is the "type safety leaks out" property of any.
unknown — safe, but you must narrow
function getUnknown(): unknown {
return JSON.parse('{"x": 1}');
}
const value = getUnknown();
// value.foo; // 💥 'value' is of type 'unknown'
// value + 1; // 💥 same
if (typeof value === 'object' && value !== null && 'x' in value) {
console.log((value as { x: number }).x); // narrowing got us here
}
Output:
1
The compiler refuses to let you touch unknown until you prove what it is. That's the
entire safety difference from any: the use requires narrowing (Lesson 33).
Tip
For unknown data from JSON.parse, prefer a type guard (Lesson 33) over a cast: a
reusable isUser(x): x is User function turns an unknown API response into a typed
value with one trustworthy check, instead of a scattered as here and there.
never — the uninhabitable type
type Never = never;
// const n: Never = 1; // 💥 Type 'number' is not assignable to type 'never'
function fail(message: string): never {
throw new Error(message);
}
// No value can ever be `never` — it's a type with zero members.
Output:
(no output — the assignment above is a compile error)
never appears where TypeScript knows something can't happen: the empty array
const x = [] in strict mode infers never[] until widened, and a switch that handled
every case of a union leaves a never in the default branch.
Real Project Usage
| Scenario | Type | Why |
|---|---|---|
JSON.parse results | unknown | You must prove the shape before use (Lesson 33 guards) |
| API layer return types | unknown | Untrusted data stays untrusted until validated |
| Migration from JS | any (temporarily) | Lifts errors while you convert files incrementally |
| Third-party libs with bad types | any | Avoids a permanent @ts-ignore |
Exhaustive switch / never check | never | Compile error if a new union member appears |
| Conditional type false branch | never | "No match" — Lesson 43's ? R : never |
| Empty collections before fill | never[] | Strict mode until values are assigned |
The flagship never pattern — exhaustive checks:
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; side: number };
function area(s: Shape): number {
switch (s.kind) {
case 'circle': return Math.PI * s.radius ** 2;
case 'square': return s.side ** 2;
default:
return assertNever(s); // ✅ any new Shape kind → compile error here
}
}
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${JSON.stringify(x)}`);
}
console.log(area({ kind: 'circle', radius: 2 }));
Output:
12.566370614359172
Add a { kind: 'triangle' } member and the default branch stops compiling — s is no
longer never. This is the strongest practical use of never in a codebase.
Interview Explanation
anyturns off checking — anything goes, and nothing is caught.unknownis the safe version: every type is assignable to it, but you can't use a value until you narrow it, so untrusted data has to be proven before it's touched.neveris the empty type — no value exists. It shows up where TypeScript proves something can't happen, like thedefaultbranch of an exhaustiveswitch, and in conditional types as the "no match" result.
Senior-Level Insights
unknownis the answer to "safeany".unknown+ a type guard (Lesson 33) is the standard way to handleJSON.parse,fetchresponses and anything crossing a boundary.anyis a policy decision, not a type. You use it deliberately at the edge of a migration, and you contain it — never let it flow through your domain model.neveris how you make exhaustive switches safe. A function(x: never) => neverin thedefaultbranch turns "we forgot a case" into a compile error the moment a new union member is added (Lesson 38).- The hierarchy question is a trap. Some interviewers ask "is
anyassignable tonever?" The precise answer:anyis assignable toneverbecauseanyis assignable to everything — it's the one exception to the bottom type's rule. never[]vs[]. An empty array literal infersnever[]in strict mode until it's given a real element type. That's thenevermost people meet first, usually while mutating state.
Common Mistakes
❌ Using any where unknown would do — you lose every error the compiler could give you.
let data: any = JSON.parse(raw); // ❌ any: no checks later
let data: unknown = JSON.parse(raw); // ✅ must narrow before use
❌ Assuming unknown is assignable to concrete types — it isn't; you must narrow it.
function f(x: unknown) {
return x.toUpperCase(); // 💥 'x' is of type 'unknown'
}
❌ Forgetting typeof narrowing: typeof x === 'object' still needs the null check.
❌ Writing never in a default branch without the assertNever function — the branch should
throw, and the function types it.
❌ Confusing never with undefined or void. undefined is a value; never is a type
with no values at all.
Best Practices
✅ Type external data as unknown at the boundary, then validate with guards (Lesson 33)
✅ Reserve any for migration escapes and genuinely untyped interop — and contain it
✅ Use assertNever in the default branch of exhaustive switches over unions
✅ Let never appear in conditional types — ? R : never is the honest "no match" (Lesson 43)
✅ Treat never[] as "array of nothing yet" and annotate it once you know the element
❌ Don't cast as any to silence an error you don't understand
❌ Don't use unknown where the type is already known — that's just losing information
Interview Questions
Q1. What's the difference between any and unknown?
anydisables checking completely — it accepts everything and lets you use it as anything, which means errors surface at runtime instead of compile time.unknownis the safe top type: everything is assignable to it, but you can't use the value until you narrow it. Sounknownis what you want for untrusted data — it forces you to prove the shape before touching it.
Q2. What is never used for?
It's the bottom type — no value inhabits it. I use it in exhaustive switches: a function
(x: never) => neverin thedefaultbranch makes the compiler error if a new union member appears. It also shows up in conditional types as the "no match" branch, and in strict modeconst x = []starts life asnever[].
Q3. Is any assignable to never?
Yes — technically.
anyis assignable to everything, includingnever. But you can never actually have anevervalue, so it's the one weird exception to the bottom type's rule. Everything else that's notanyis not assignable tonever.
Q4. When would you choose unknown over any?
Whenever the value crosses a trust boundary:
JSON.parse, afetchresponse, data fromlocalStorage.unknownforces me to narrow it — with a type guard — before use.anyskips that and pushes the failure into production.
Q5. Can you have a value of type never?
No —
neveris uninhabitable, by definition. You can only infer it: an exhaustive switch's default branch, a conditional type's false branch, an empty array before it's widened. The value never exists; the type is TypeScript telling you something can't happen.
Senior follow-up: How would you type an API response you don't trust?
type User = { id: number; name: string }; function isUser(x: unknown): x is User { return typeof x === 'object' && x !== null && 'id' in x && typeof (x as { id: unknown }).id === 'number' && 'name' in x && typeof (x as { name: unknown }).name === 'string'; } async function fetchUser(): Promise<User> { const res = await fetch('/api/user'); const data: unknown = await res.json(); if (!isUser(data)) throw new Error('malformed user'); return data; // ✅ narrowed: unknown → User } console.log(await fetchUser().catch(e => e.message));Output:
malformed userThe response starts as
unknown, gets validated by a guard, and only then becomes aUser.anywould have let the unvalidated shape flow everywhere; this keeps the check at the boundary where it belongs.
Follow-up Questions
What's the type of [] in strict mode, and why?
never[]— the compiler can't know what elements will be pushed, so it infers the uninhabited element type until a real element type arrives or you annotate it.
Why does an exhaustive switch need assertNever?
Without it, adding a new union member makes the
defaultbranch silently fall through at runtime. With(x: never) => never, the compiler itself reports the uncovered member.
Is void the same as undefined or never?
No.
voidis the return type for functions whose result you ignore — the value is undefined-ish.undefinedis a real value.neverhas no value at all; aneverfunction never returns, it throws or hangs.
What happens when unknown meets a union?
unknownabsorbs the union —unknown | stringcollapses tounknown. Likewiseneverdisappears from unions:never | stringisstring. The top type and the bottom type are each other's identity elements.
Comparison Table
any | unknown | never | |
|---|---|---|---|
| Kind | escape hatch | safe top type | bottom type |
| Values it accepts | everything | everything | nothing |
| Use without narrowing | ✅ | ❌ | — |
| Assignable to concrete types | ✅ (anything) | ❌ | ✅ (vacuously) |
| Catches errors | ❌ | ✅ (forces checks) | ✅ (exhaustiveness) |
| Typical use | migration, bad libs | API/data boundaries | exhaustive switches, no-match branches |
| In a union | absorbs | absorbs | disappears |
| In conditional types | often a mistake | the "need to check" input | the "no match" output |
Code Example
One flow showing all three:
type Result = { ok: true; data: string } | { ok: false; error: string };
// unknown in: JSON from an untrusted source
const raw: unknown = '{"ok":true,"data":"hello"}';
function parseResult(x: unknown): Result | never {
if (typeof x !== 'object' || x === null) return { ok: false, error: 'not an object' };
if (!('ok' in x)) return { ok: false, error: 'missing ok' };
return { ok: true, data: String((x as { data?: unknown }).data ?? '') };
}
const parsed: Result = parseResult(raw); // unknown → narrowed → Result
console.log(parsed);
// any — the escape hatch, deliberately avoided above
const anything: any = parsed;
console.log('still compiles with any:', anything.ok);
Output:
{ ok: true, data: 'hello' }
still compiles with any: truePerformance Notes
All three are compile-time only — zero runtime cost. The interesting performance story
is negative: any has no compile-time cost but pushes failures to runtime (which costs
everything); unknown trades a little checking time for correctness; never costs nothing.
At runtime there is no difference at all between the three — the emitted JavaScript is
identical. unknown's only "cost" is the extra guard code you write, which is the point.
Debugging Scenarios
"'value' is of type 'unknown' everywhere." You're using the value without narrowing.
Add a guard (typeof, in, a custom type predicate) before use — that's the feature, not
a bug.
"I can't get rid of the any without breaking 40 call sites." That's a migration
signal: start with unknown at the boundary, add guards, and type the smallest
surface area. any confined to one function beats any flowing through forty.
"My exhaustive switch no longer errors when I add a member." The default branch is
probably missing assertNever, or the function's parameter is typed more broadly than
never. Add the (x: never) => never helper and the error returns.
"const x = [] won't let me push." Strict mode inferred never[]. Annotate it —
const x: string[] = [] — and the push is fine.
Quick Revision Notes
any— checks off; use at the edge, contain itunknown— everything fits in, nothing comes out until narrowednever— zero values; inferred, never held- Narrow
unknownwith guards (Lesson 33) before touching it assertNeverin thedefaultof an exhaustive switchanyis assignable tonever— the one exceptionnever | Tcollapses toT;unknown | Tcollapses tounknownconst x = []isnever[]in strict mode- All three are compile-time only — no runtime footprint
Cheat Sheet
// any — escape hatch, no checks
let anything: any = 1;
anything.foo.bar; // ✅ compiles, 💥 at runtime
// unknown — safe top type, must narrow
let safe: unknown = { x: 1 };
if (typeof safe === 'object' && safe !== null) {
// safe is usable here
}
// never — uninhabitable, inferred not held
type Bottom = never;
function fail(): never { throw new Error('nope'); }
// the exhaustive-check pattern
function assertNever(x: never): never {
throw new Error(`Unexpected: ${JSON.stringify(x)}`);
}Key Takeaways
Recap
anydisables checking — use it as a contained escape hatch, not a lifestyleunknownis the safe top type: everything in, nothing out until narrowedneveris the bottom type — no value exists; you infer it, you never hold it- Narrow
unknownat the boundary with type guards from Lesson 33 assertNevermakes exhaustive switches self-policinganyis assignable tonever;never | TisT;unknown | Tisunknown- All three cost nothing at runtime — the choice is purely about checking
Check your understanding
Answer each one out loud. Then rate yourself honestly.
0/7Give the one-sentence difference between any, unknown and never.
Why can't you use an unknown value directly — and how do you make it usable?
What's the difference between never and undefined?
Write the assertNever pattern and explain what it protects against.
Why is any assignable to never?
What does unknown | string collapse to? What does never | string collapse to?
Where would you use unknown in a real codebase instead of any?
What's Next
Lesson 46 — tsconfig & Strict Mode. What strictNullChecks actually turns on, and why
teams stage the migration.