Emmanuel Akinfulubi

Lesson 12 of 1816 min read

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.

any permits everything, including nonsense

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.

One any, and three later mistakes go unreported

// 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


      

The same code with unknown, caught at the first step

TS2345
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.

unknown blocks the reach

TS18046
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'.

Narrow it, and it becomes usable

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


      

JSON.parse returns any, and that is worth fixing

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

A caught value could be anything

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


      

Assuming a caught value is an Error

TS18046
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'.

toError, and why you want it

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

Throwing and looping forever

// 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.

Why assertNever works

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


      

Add a member, and never stops being never

TS2345
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 vanishes from a union, which is what makes filtering work

// 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

The same function, four ways

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.

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.

Containing an any, rather than exporting one

// 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

What is assignable to what

// 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


      

unknown will not go anywhere without a check

TS2322
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

  1. Write a function taking any that reads three levels of nested property. Confirm it compiles. Change the parameter to unknown and count how many errors appear — that number is how much checking the any was suppressing.
  2. Write toError(value: unknown): Error from this lesson and use it in a try/catch around code that throws a string.
  3. Parse some JSON into an unknown and write a check that narrows it to a shape with two properties. Notice how much of the work is checking rather than parsing.
  4. Write a discriminated union with three members and a switch with assertNever. Add a fourth member and read the error message carefully — it should name the member you forgot.
  5. Write a function returning never that throws, then use it in an if and confirm the compiler narrows the type after the call.
  6. Find an any in 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 it unknown.

Recap

  • any switches checking off and spreads to everything it touches, because a property of any is any.
  • unknown accepts anything and lets you do nothing until you narrow, which makes it the right type for every unverified value entering your program.
  • JSON.parse returns any, and a caught error is unknown. Both are boundaries worth handling deliberately.
  • never is the type with no values. It is what a non-returning function returns, and what a fully-handled union collapses to — which is why assertNever works.
  • never disappears from a union, and that disappearance is the mechanism behind type-level filtering.
  • any is defensible when it is contained inside a function body and commented. In a signature it becomes everybody's problem.

Saved in this browser only.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.