TypeScript design patterns · 8 / 8
lesson 8

The unknown problem

any silently lies. unknown forces you to check. The difference is the whole game.

~ 14 min read·lesson 8 of 8
0 / 8

You read a JSON response from an API. You stuff the result into a variable and use it on the next line: data.user.email. Three months later the API renames user to account. Your code keeps compiling. It just crashes when it runs, in a place far from the network call. The variable was typed any, and any agreed to anything you asked it. This lesson is about the type that doesn't.

What any actually does

any is TypeScript's escape hatch. A value of type any is allowed to be anything and is allowed to be used as anything. Reading any property succeeds. Calling any method succeeds. Assigning to any other type succeeds. The compiler gives up on the value entirely.

any.ts
function readResponse(data: any) {
console.log(data.user.email.toLowerCase());   /* compiles */
data();                                       /* compiles */
const n: number = data;                       /* compiles */
}

Every line compiles. None are checked. If data is a string, data.user is undefined, and data.user.email throws at runtime. The compiler had information you needed and threw it away. any doesn't fail safe — it fails everywhere except where the failure happens.

any spreads. A function that returns any poisons everything downstream. The variable that holds the call's result is any. The fields you read off it are any. Soon a whole module is effectively untyped, and the only feedback you'll get is from runtime crashes.

Watch out

Reach for any only when you genuinely have no information about the value and you accept that the compiler can't help. In ten years of TypeScript, the right answer is almost always unknown instead.

What unknown does instead

unknown accepts anything — same as any — but it does not let you do anything with the value until you've narrowed it. You can't read properties, call it, or assign it to a specific type. The compiler treats unknown as a signal that you are at the boundary, and it makes you cross that boundary explicitly.

unknown.ts
function readResponse(data: unknown) {
console.log(data.user.email);   /* error: Object is of type 'unknown'. */
data();                          /* error */
const n: number = data;          /* error */
}

All three lines are now compile errors. The function takes any value at all, but the compiler refuses to trust the shape of that value until you check it. The contract is "I'll accept anything; you'll have to look at it before you use it."

anyvalue: anyuse freely unknownvalue: unknownnarrowuse safely
any lets you skip checks. unknown forces them. Both accept the same inputs.

Use unknown for parameters that come from outside your code. Network responses, JSON.parse results, localStorage reads, dynamic imports, postMessage payloads. Anywhere the value could be anything, unknown is the right starting type.

Narrowing unknown

You convert unknown to a usable type by checking it. The same narrowing tools that work on discriminated unions work here.

narrow.ts
function shout(data: unknown): string {
if (typeof data === "string") {
  return data.toUpperCase();   /* data is now string */
}
if (typeof data === "number") {
  return String(data);
}
return "?";
}

typeof data === "string" is a narrowing check the compiler understands. Inside the if, data is a stringtoUpperCase is available, and the call compiles. Outside the if, data is back to unknown. Each branch carries the type the check has just established.

The same applies to richer checks. data instanceof Error. Array.isArray(data). data === null. Every one of those narrows the type to whatever the check proves.

object-narrow.ts
function readEmail(data: unknown): string | null {
if (
  typeof data === "object" &&
  data !== null &&
  "email" in data &&
  typeof (data as { email: unknown }).email === "string"
) {
  return data.email;
}
return null;
}

This is the verbose version. You check that data is an object, isn't null, has an email key, and that key's value is a string. Once all four checks pass, data.email is safe to read as a string. The verbosity is the cost of being honest about an unknown shape — and it's exactly the cost any was trying to skip past.

check your understanding
A function takes data: unknown. You write const len = data.length;. What does the compiler say?

Type predicates as a contract

The verbose narrowing above is fine for one-off checks. When the same narrowing is reused, package it in a function that returns a type predicate — a return type of the form value is SomeType. The compiler reads the predicate as "if this returns true, the value is of that type."

predicate.ts
type User = { email: string; name: string };

function isUser(x: unknown): x is User {
return (
  typeof x === "object" &&
  x !== null &&
  typeof (x as { email?: unknown }).email === "string" &&
  typeof (x as { name?: unknown }).name === "string"
);
}

function greet(data: unknown): string {
if (isUser(data)) {
  return "Hello, " + data.name;   /* data is now User */
}
return "Hello, stranger";
}

The signature x is User is a promise from you to the compiler. If you return true, the caller's variable is narrowed to User. The compiler trusts you here — it doesn't verify the body of isUser against the predicate. So the predicate is a contract, and writing one wrong is a real risk.

Watch out

Type predicates are unchecked. If your function returns true for values that aren't the type you promised, the rest of your code believes a lie. Treat predicates like branded-type constructors — one place per type, with the validation actually written down.

A predicate is a building block. A library that handles parsing — Zod, Valibot, io-ts, ArkType — generates predicates plus errors plus parsed values from a schema definition. The next course in this track digs into that workflow. The pattern you just walked through is the foundation under all of those tools.

Parsing vs validating

A note on shape, since it sets up the next course. There's a useful split between validating a value (returning a boolean — yes or no) and parsing a value (returning either the parsed value or a structured error).

A type predicate validates. isUser(data) returns a boolean and narrows. That's enough when you have the shape and just want to confirm it.

A parser does more. It takes unknown, returns Result<User, ValidationError> (or throws — your call from lesson 6), and gives you back a value that's been verified field-by-field with errors that name the failing field. Parsing produces a value the type system can fully trust without further checks.

parse.ts
function parseUser(data: unknown): Result<User, string> {
if (!isUser(data)) return { ok: false, error: "expected User shape" };
return { ok: true, value: data };
}

For most app code, parse at the boundary and use the parsed type everywhere afterward. The boundary is the place data enters your code from outside — a fetch, a form, a database driver, a message bus. Inside the boundary, the value is a fully-typed User and you trust it. Outside the boundary, the value is unknown and you don't.

That single line of discipline — unknown outside, parsed types inside — eliminates a category of bug that runtime type errors used to make routine.

check your understanding
What's the practical difference between a parameter typed any and one typed unknown?
check your understanding
You write function isString(x: unknown): x is string { return true; }. What's wrong with it?
check your understanding
Where in your codebase does unknown belong, and where does the parsed type belong?
← prevfinish course →
KeepLearningcertificate
for completing
TypeScript design patterns
0 of 8 read