any, unknown and never
The escape hatch that switches checking off, the one that keeps it on, and the type that means this cannot happen.
By the end: Reach for unknown instead of any, and read never correctly.
Three types that sit at the edges of the system. One switches checking off, one keeps it on while admitting you do not know, and one means “this cannot happen”.
They are grouped together because they are all about the limits of what you know, and because choosing correctly between the first two is one of the higher-leverage habits available to you.
any: the off switch
any is not a type so much as an instruction to stop thinking about types for this value. Every operation on it is permitted, and no mistake involving it is reported.
const value: any = 'just a string';
// All three of these compile without complaint. Only the first is correct.
console.log('length:', value.length);
console.log('toFixed:', typeof value.toFixed); // undefined: strings have none
// And this one is a real crash, waved through at compile time.
try {
console.log(value.anything.at.all);
} catch (error) {
console.log('at run time:', (error as Error).constructor.name);
console.log((error as Error).message);
}
Output
any is contagious
The real cost is not the one value. It is that any spreads through everything it touches, quietly disabling checking in code that never mentioned it.
// Pretend this came from JSON.parse, or an untyped library.
const response: any = { user: { name: 'Ada' } };
// `user` is any, because a property of any is any.
const user = response.user;
// `name` is any too. The infection has spread two levels from the source.
const name = user.name;
// And now this compiles, despite being obviously wrong.
const shouted: number = name.toUpperCase();
console.log('the compiler had no objection to any of that');
console.log('at run time:', typeof shouted, shouted);
Output
const response: unknown = { user: { name: 'Ada' } };
function greet(name: string): string {
return `Hello ${name}`;
}
// unknown does not let you reach into it. The mistake is caught immediately,
// at the boundary, rather than three assignments later.
console.log(greet(response));
What the compiler says
error TS2345: Argument of type 'unknown' is not assignable to parameter of type 'string'.
unknown: honest about not knowing
unknown accepts any value, exactly like any. The difference is on the way out: you can do nothing with an unknown until you have established what it is.
const value: unknown = 'a string, as it happens';
// The compiler does not care that it is really a string. You have not proved
// it, so nothing is available.
console.log(value.length);
What the compiler says
error TS18046: 'value' is of type 'unknown'.
function describe(value: unknown): string {
// Everything from lesson 05 works here. Narrowing is how you get out of
// unknown, and the compiler tracks it exactly as it would for a union.
if (typeof value === 'string') return `string of length ${value.length}`;
if (typeof value === 'number') return `number: ${value.toFixed(2)}`;
if (typeof value === 'boolean') return `boolean: ${value}`;
if (value === null) return 'null';
if (Array.isArray(value)) return `array of ${value.length}`;
if (value instanceof Date) return `date: ${value.toISOString().slice(0, 10)}`;
if (typeof value === 'object') return `object with keys ${Object.keys(value).join(', ')}`;
return 'something else entirely';
}
console.log(describe('text'));
console.log(describe(42));
console.log(describe(true));
console.log(describe(null));
console.log(describe([1, 2, 3]));
console.log(describe(new Date('2026-01-01')));
console.log(describe({ a: 1, b: 2 }));
Output
const raw = '{"id": 7, "name": "Ada"}';
// JSON.parse is declared as returning `any`, which is a historical decision
// you cannot change — but you can refuse to accept it.
const asAny = JSON.parse(raw);
console.log('any lets this through:', typeof asAny.id.nonsense);
// Annotating the destination as unknown re-enables checking immediately.
const asUnknown: unknown = JSON.parse(raw);
function isUser(value: unknown): value is { id: number; name: string } {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
typeof (value as { id: unknown }).id === 'number' &&
'name' in value &&
typeof (value as { name: unknown }).name === 'string'
);
}
if (isUser(asUnknown)) {
console.log('checked:', asUnknown.id, asUnknown.name.toUpperCase());
} else {
console.log('not the shape we expected');
}
Output
catch, where unknown is now the default
function risky(mode: string): string {
if (mode === 'throw-error') throw new Error('a proper error');
if (mode === 'throw-string') throw 'just a string';
if (mode === 'throw-object') throw { code: 500 };
return 'no problem';
}
for (const mode of ['ok', 'throw-error', 'throw-string', 'throw-object']) {
try {
console.log(risky(mode));
} catch (error) {
// `error` is unknown, because JavaScript lets you throw any value at all.
// Assuming it is an Error is the single most common unsafe assumption in
// TypeScript codebases.
if (error instanceof Error) console.log(`Error: ${error.message}`);
else console.log(`Threw a non-Error: ${JSON.stringify(error)}`);
}
}
Output
try {
throw 'not an Error object';
} catch (error) {
// Nothing guarantees this. `throw` accepts any value.
console.log(error.message);
}
What the compiler says
error TS18046: 'error' is of type 'unknown'.
function toError(value: unknown): Error {
if (value instanceof Error) return value;
if (typeof value === 'string') return new Error(value);
return new Error(`Non-error thrown: ${JSON.stringify(value)}`);
}
const thrown: unknown[] = [new Error('real'), 'string', { code: 500 }, 42];
for (const value of thrown) {
const error = toError(value);
// From here on, everything downstream can rely on Error's shape.
console.log(`${error.name}: ${error.message}`);
}
Output
never: there is no such value
never is the type with no values in it. Nothing is assignable to it, and a function returning it does not return at all. It looks academic and then turns up in three genuinely practical places.
Functions that do not return
// Returns never, because it always throws. Note the difference from void:
// void means "returns nothing", never means "does not return".
function fail(message: string): never {
throw new Error(message);
}
// Useful because the compiler knows control flow stops here, which means the
// narrowing below works.
function parsePort(input: string): number {
const n = Number(input);
if (Number.isNaN(n)) fail(`not a number: ${input}`);
// The compiler knows fail() cannot return, so `n` is a usable number here
// without any further checking.
return n;
}
console.log(parsePort('8080'));
try {
parsePort('abc');
} catch (error) {
console.log('caught:', (error as Error).message);
}
Output
The exhaustiveness check
This is the payoff from lesson 04, now explained. When every member of a union has been handled, the value in the remaining branch has type never — because there is nothing left it could be.
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; side: number };
function assertNever(value: never): never {
throw new Error(`Unhandled: ${JSON.stringify(value)}`);
}
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle': return Math.PI * shape.radius ** 2;
case 'square': return shape.side ** 2;
default:
// Both members handled, so `shape` here is never, and passing never to
// a parameter of type never is the one thing that is allowed.
return assertNever(shape);
}
}
console.log(area({ kind: 'circle', radius: 1 }).toFixed(2));
console.log(area({ kind: 'square', side: 3 }));
Output
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; side: number }
| { kind: 'triangle'; base: number; height: number };
function assertNever(value: never): never {
throw new Error(`Unhandled: ${JSON.stringify(value)}`);
}
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle': return Math.PI * shape.radius ** 2;
case 'square': return shape.side ** 2;
default:
// `triangle` is unhandled, so `shape` here is the triangle member, not
// never. The error names exactly what you forgot.
return assertNever(shape);
}
}
console.log(area({ kind: 'circle', radius: 1 }));
What the compiler says
error TS2345: Argument of type '{ kind: "triangle"; base: number; height: number; }' is not assignable to parameter of type 'never'.
never in unions, where it disappears
// never contributes nothing to a union, so these are the same type.
type A = string | never; // just string
type B = string | number;
const a: A = 'text';
const b: B = 42;
console.log(a, b);
// That disappearing act is the mechanism behind the key-filtering trick from
// lesson 10: unwanted keys are mapped to never, and then they are simply gone.
interface User {
id: number;
name: string;
email: string;
active: boolean;
}
type StringKeys<T> = {
[K in keyof T]: T[K] extends string ? K : never;
}[keyof T];
// 'name' | 'email'. The id and active keys became never and evaporated.
const key: StringKeys<User> = 'email';
console.log('a string-valued key:', key);
Output
Choosing between them
interface Config {
retries: number;
}
// 1. any: compiles, checks nothing, and will throw when handed rubbish.
function withAny(input: any): number {
return input.retries * 2;
}
// 2. unknown: forces you to check, which is the honest version.
function withUnknown(input: unknown): number {
if (typeof input === 'object' && input !== null && 'retries' in input) {
const retries = (input as Config).retries;
if (typeof retries === 'number') return retries * 2;
}
return 0;
}
// 3. A specific type: best when you control the caller and can insist.
function withConfig(input: Config): number {
return input.retries * 2;
}
// 4. object: accepts any non-primitive, and is almost never what you want,
// because you still cannot read any properties from it.
function withObject(input: object): number {
return Object.keys(input).length;
}
console.log(withAny({ retries: 3 }));
console.log(withUnknown({ retries: 3 }), withUnknown('rubbish'));
console.log(withConfig({ retries: 3 }));
console.log(withObject({ retries: 3 }));
// And the difference that matters:
console.log('any with rubbish:', (() => {
try { return withAny('rubbish'); } catch { return 'threw'; }
})());
Output
Where any is genuinely defensible
Being absolute about this would be dishonest. There are a few places any is the pragmatic answer.
- Migrating a large JavaScript codebase. Getting the project compiling with some
anyin it, then removing them file by file, is far better than a six-month rewrite. Lesson 18 covers this. - Genuinely dynamic code — a deep-equality helper, a generic serialiser — where every alternative is more obscure than the
any. Even here,unknownplus a few internal casts is usually achievable. - Working around a wrong type definition in a dependency. Cast, add a comment linking to the issue, and move on.
What they have in common: the any is contained, and there is a comment saying why. An any in a function signature leaks into every caller; an any inside one function body does not.
// The dangerous version: `any` in the signature infects every caller.
function badParse(json: string): any {
return JSON.parse(json);
}
// The contained version: `any` exists for one line inside the function, and
// the signature it presents to the world is honest.
function goodParse(json: string): unknown {
return JSON.parse(json) as unknown;
}
const bad = badParse('{"a":1}');
const good = goodParse('{"a":1}');
// Callers of badParse get no help at all.
console.log('bad is any, so anything goes:', typeof bad);
// Callers of goodParse are made to check, which is the point.
if (typeof good === 'object' && good !== null && 'a' in good) {
console.log('good, after checking:', (good as { a: number }).a);
}
What this compiles to
// The dangerous version: `any` in the signature infects every caller.
function badParse(json) {
return JSON.parse(json);
}
// The contained version: `any` exists for one line inside the function, and
// the signature it presents to the world is honest.
function goodParse(json) {
return JSON.parse(json);
}
const bad = badParse('{"a":1}');
const good = goodParse('{"a":1}');
// Callers of badParse get no help at all.
console.log('bad is any, so anything goes:', typeof bad);
// Callers of goodParse are made to check, which is the point.
if (typeof good === 'object' && good !== null && 'a' in good) {
console.log('good, after checking:', good.a);
}
Output
A quick reference
// Everything is assignable to any and to unknown.
const toAny: any = 'anything';
const toUnknown: unknown = 'anything';
console.log(typeof toAny, typeof toUnknown);
// any is assignable to everything, which is exactly the problem.
const fromAnyToNumber: number = toAny;
console.log('any became a number without a murmur:', typeof fromAnyToNumber);
// unknown is assignable to nothing except any and unknown.
const fromUnknownToUnknown: unknown = toUnknown;
console.log('unknown to unknown is fine:', typeof fromUnknownToUnknown);
// never is assignable to everything, and nothing is assignable to never.
// This type-checks: a never-returning call can stand in for any type. It is
// guarded here because running it would, correctly, throw.
function nope(): never { throw new Error('nope'); }
function lengthOrDie(value: string | null): number {
if (value === null) nope();
// The compiler accepts this because nope() cannot return, so `value` is a
// string from here on. That is `never` doing useful work.
return value.length;
}
console.log('with a string:', lengthOrDie('four'));
try {
lengthOrDie(null);
} catch (error) {
console.log('with null:', (error as Error).message);
}
Output
const value: unknown = 'a string';
const text: string = value;
console.log(text);
What the compiler says
error TS2322: Type 'unknown' is not assignable to type 'string'.
Your turn
- Write a function taking
anythat reads three levels of nested property. Confirm it compiles. Change the parameter tounknownand count how many errors appear — that number is how much checking theanywas suppressing. - Write
toError(value: unknown): Errorfrom this lesson and use it in atry/catcharound code that throws a string. - Parse some JSON into an
unknownand write a check that narrows it to a shape with two properties. Notice how much of the work is checking rather than parsing. - Write a discriminated union with three members and a
switchwithassertNever. Add a fourth member and read the error message carefully — it should name the member you forgot. - Write a function returning
neverthat throws, then use it in anifand confirm the compiler narrows the type after the call. - Find an
anyin your own code, or in a dependency's types. Work out whether it is contained inside a function or exposed in a signature, and what it would take to make itunknown.
Recap
anyswitches checking off and spreads to everything it touches, because a property ofanyisany.unknownaccepts anything and lets you do nothing until you narrow, which makes it the right type for every unverified value entering your program.JSON.parsereturnsany, and a caught error isunknown. Both are boundaries worth handling deliberately.neveris the type with no values. It is what a non-returning function returns, and what a fully-handled union collapses to — which is whyassertNeverworks.neverdisappears from a union, and that disappearance is the mechanism behind type-level filtering.anyis defensible when it is contained inside a function body and commented. In a signature it becomes everybody's problem.