Every program has a boundary where typed code meets values it did not create: a JSON response, a localStorage string, a message from a worker, a thrown value in a catch. TypeScript cannot know the shape of any of those. It gives you two ways to say so, and they behave in opposite ways.

any says "stop checking." unknown says "check before you use this."

Why any leaks

any is not a type. It is a switch that turns the type checker off for a value, and the switch travels.

const config: any = JSON.parse(raw)

const port = config.port        // any
const host = port.toUpperCase() // any — no error, port is a number
const url = `http://${host}`    // any, and wrong at runtime

Nothing above is reported. any propagates through property access, through function returns, and into every variable that touches it, so a single any at a boundary can disable checking in code written months later by someone who never saw the boundary. The error surfaces at runtime, far from its cause.

This is the actual argument against any: not that it is unsafe where you wrote it — you knew what you were doing there — but that it is unsafe somewhere you are not looking.

unknown is the same idea with the propagation removed. You can assign anything to unknown, and you can do almost nothing with it until you prove what it is.

const config: unknown = JSON.parse(raw)

config.port // Error: 'config' is of type 'unknown'

const leaked = (config as any).port // Compiles, and you have just re-opened the hole

The error is the feature. It appears at the boundary, which is the one place someone is thinking about the shape of the data.

Narrowing an unknown

Everything you already know about narrowing applies. unknown is just the widest possible starting point.

function describe(value: unknown): string {
  if (typeof value === "string") {
    return value.toUpperCase()
  }
  if (typeof value === "number") {
    return value.toFixed(2)
  }
  if (Array.isArray(value)) {
    return `array of ${value.length}`
  }
  if (value instanceof Date) {
    return value.toISOString()
  }
  return "unrecognised"
}

Objects need one more step, because typeof x === "object" is true for null and tells you nothing about properties:

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value)
}

function readPort(value: unknown): number | null {
  if (!isRecord(value)) return null
  if (typeof value["port"] !== "number") return null
  return value["port"]
}

Record<string, unknown> is the useful intermediate step. Its values are still unknown, so you keep having to check as you drill in, one level at a time. That is tedious exactly in proportion to how little you actually know about the input.

fill it in

1 blank · graded here, free

function describe(value: unknown): string {
  if ( value === "string") {
    return value.toUpperCase()
  }
  return "unrecognised"
}

type into the gaps, then check

Type predicates, and what they cost

A function returning value is T teaches the compiler something it cannot verify. That is the point, and it is also the risk: a predicate is an assertion with better manners.

interface User {
  id: string
  name: string
}

function isUser(value: unknown): value is User {
  return (
    isRecord(value) &&
    typeof value["id"] === "string" &&
    typeof value["name"] === "string"
  )
}

That predicate checks every property in the type, so the claim it makes is true. This one is not:

function isUser(value: unknown): value is User {
  return isRecord(value) && "id" in value // never checked name
}

It compiles, it narrows, and every caller now treats user.name as a string that may be undefined. A predicate that under-checks is a cast that nobody will review as one, because it does not contain the word as.

The rule that keeps this honest: a predicate must check every property of the type it claims. When the type grows a field, the predicate has to grow a check — and nothing in the compiler will remind you, which is why predicates belong in one small module you can read in full rather than scattered across the codebase.

If you find yourself writing many of these by hand, that is the signal to reach for a schema validation library. The narrowing is the same; the difference is that the checks are derived from the type rather than maintained in parallel with it.

fill it in

1 blank · graded here, free

function isUser(value: unknown): value  User {
  return (
    isRecord(value) &&
    typeof value["id"] === "string" &&
    typeof value["name"] === "string"
  )
}

type into the gaps, then check

Assertion functions at a boundary

When a bad value means the operation cannot continue, an assertion function is tighter than a predicate, because it narrows the rest of the enclosing scope rather than one branch.

function assertIsUser(value: unknown): asserts value is User {
  if (!isUser(value)) {
    throw new TypeError("Expected a User")
  }
}

async function loadUser(id: string): Promise<User> {
  const body: unknown = await fetch(`/api/users/${id}`).then((r) => r.json())
  assertIsUser(body)
  return body // User, for the rest of the function
}

Note the explicit : unknown annotation on body. Without it the value is any, because Response.json() is typed as returning Promise<any> — the leak this whole page is about, sitting in the standard library on the most common boundary in a web application.

JSON.parse has the same signature. Wrapping it once is worth it:

export function parseJson(text: string): unknown {
  return JSON.parse(text)
}

That single-line function changes the default for every caller: instead of receiving permission to skip checking, they receive a value they have to inspect.

Errors are unknown

With useUnknownInCatchVariables enabled — it is included in strict — a caught value is unknown, which is correct: JavaScript permits throwing anything, and plenty of libraries throw strings.

function messageOf(error: unknown): string {
  if (error instanceof Error) return error.message
  if (typeof error === "string") return error
  return "Unknown error"
}

try {
  await save(record)
} catch (error) {
  logger.error(messageOf(error))
}

error.message inside the catch is the single most common place any gets reintroduced, usually as catch (error: any). One helper removes the temptation everywhere. The rule for this whole page is that small: use unknown where data enters your program, narrow it once, deliberately, at that boundary, and everything downstream works with real types.

fill it in

1 blank · graded here, free

function messageOf(error: unknown): string {
  if (error  Error) return error.message
  return "Unknown error"
}

type into the gaps, then check

Where this bites

Banning any outright. Sometimes a third-party type definition is wrong, or you're writing the internals of a generic utility where the type genuinely can't be spoken, and any is the tool for that — contained to one line with a comment saying why, and a typed value on the way back out:

// The upstream types omit the callback overload; verified against v4.2 source.
const client = createClient(options) as any as TypedClient

The problem was never the keyword. It's any values escaping into code that never agreed to handle them.

catch (error: any). It reintroduces the exact leak this page is about, at the single most common boundary in a web application. Annotate the caught value as unknown — or leave it unannotated, since useUnknownInCatchVariables makes that the default under strict — and write one messageOf helper the whole codebase can share.

A predicate that checks one property and claims the whole type. isUser returning isRecord(value) && "id" in value compiles, narrows, and quietly ships user.name as a string that is actually undefined everywhere it's read. Review a predicate as the cast it secretly is, and confirm it checks every property in the type it claims to prove.

Trusting JSON.parse or response.json() without an explicit unknown annotation. Both are typed to return any in the standard library, so the moment you write const data = await res.json() with no annotation, the leak has already happened, silently, on the most common boundary in the app. Wrap them once so every caller receives a value they have to inspect before they can use it.