TypeScript unknown vs any: Handling Runtime Data

Published Updated

A TypeScript app can receive data from an API, a form, a file, or an environment variable. TypeScript cannot check that incoming data before the program runs. Use unknown and validate its contents before using them; any lets you skip those checks.

This is the last guide in the types topic, and it leans on the narrowing techniques from union types and narrowing.

Operationanyunknown
Accept an incoming valueAccepts any valueAccepts any value
Read a property or call a methodAllowed without checkingRequires narrowing or an assertion
Assign to a string variableAllowed without proving it is textRequires narrowing or an assertion
Validate external data at runtimeDoes not validate itDoes not validate it; your checking code does

What Does any Do in TypeScript?

TypeScript any disables most type checks on a value. You can access a property or call a method even when the compiler has no reason to believe it exists:

const payload: any = JSON.parse('{"name": "Ada"}');

const greeting: string = payload.nmae.toUpperCase();

console.log(greeting);
// tsc output: no errors.
// runtime output:
// TypeError: Cannot read properties of undefined (reading 'toUpperCase')

The typo nmae sailed through the compiler, and so did the assignment into a supposedly safe string. Because any is assignable to every type except never, an unchecked value can pass into variables that appear safely typed.

Note that the compiler produced this any for free, because JSON.parse is typed to return it. You do not have to write any anywhere to have it in your code; you inherit it from boundaries.

Why Use unknown for External Data in TypeScript?

TypeScript unknown accepts any value but requires narrowing before you access its properties or call its methods. That makes it a safer starting point for external data:

function parseJson(input: string): unknown {
  return JSON.parse(input);
}

const data = parseJson('{"name": "Ada"}');

console.log(data.name);
// tsc output:
// error TS18046: 'data' is of type 'unknown'.

any moves the failure to runtime, and unknown moves it to the compiler. The error is annoying in exactly the right place, at the line that assumed a shape nobody has checked yet.

The asymmetry is easy to remember. Everything is assignable to both, but unknown is assignable to almost nothing: only unknown and any themselves, plus, since TypeScript 4.8, the union {} | null | undefined.

So an unknown cannot leak into a string variable the way an any can; the type system forces a narrowing check or an explicit assertion first. That one-line parseJson wrapper is the cheapest safety upgrade in this guide.

Validating Runtime Data

Narrowing an unknown object takes more than a single typeof, so the standard tool is a type guard: a function returning a type predicate, value is User, whose body does the real runtime checking:

type User = {
  id: string;
  email: string;
  displayName: string | null;
};

function isUser(value: unknown): value is User {
  if (typeof value !== "object" || value === null) {
    return false;
  }

  const candidate = value as Record<string, unknown>;

  return (
    typeof candidate.id === "string" &&
    typeof candidate.email === "string" &&
    (typeof candidate.displayName === "string" || candidate.displayName === null)
  );
}

const parsed = parseJson('{"id": "u1", "email": "ada@example.com", "displayName": null}');

if (isUser(parsed)) {
  console.log(parsed.email); // parsed: User in this branch
}
// prints: ada@example.com

When isUser returns true, the compiler narrows the argument to User in that branch, and the shape flows through the rest of the code with full checking. The guard is trusted, not verified. TypeScript does not prove your checks match the predicate, so a lazy guard body is a hole. Keep guards small and boring.

Hand-written guards scale poorly past a few shapes, which is why schema validation libraries exist. You describe the shape once as a schema, the library validates at runtime and reports what failed, and the static type is inferred from the same schema so the two cannot drift. Validate the response before passing it to the rest of your application.

Type Assertions and Their Cost

The tempting shortcut is an assertion. as User makes the error disappear, and it is worth being precise about what it does at runtime: nothing. The compiler rejects assertions whose source and target do not sufficiently overlap, but an accepted assertion changes its belief without emitting a single runtime check:

const user = JSON.parse('{"id": "u1"}') as User;

console.log(user.email.toLowerCase());
// tsc output: no errors.
// runtime output:
// TypeError: Cannot read properties of undefined (reading 'toLowerCase')

The object has no email, but the assertion told the compiler it does, so the crash arrives at runtime with the compiler's blessing. Worse, the false belief persists. Every later use of user is checked against a shape that was never true. An assertion is a claim, and its cost is that nothing at runtime ever verifies the claim.

Assertions have honest uses. Narrowing inside a guard body, as isUser did with Record<string, unknown>, is standard. So is asserting a type you know from context the compiler lacks, such as a DOM element you just created.

The overlap check is only a speed bump. A double assertion through unknown or any means "I have stopped asking", and asserting from a value already typed any gets effectively no protection in the first place.

When the value is a fresh literal you construct in the code, satisfies is often the better claim. Writing config satisfies AppConfig checks the literal against the type while keeping its narrower inferred type, so a typo in a key is an excess-property error, where as AppConfig can suppress it when the value otherwise overlaps enough.

The same limits from the FAQ apply: an any-typed expression satisfies anything, and inner assertions can hide a mismatch. For data that only arrives at runtime, satisfies performs no runtime validation, only a static check of the expression, and a guard remains the answer.

Use a guard or schema validator for unverified API responses. An as assertion cannot tell you whether the server sent the fields you need.

Which Data Needs Runtime Validation in TypeScript?

Validate data your program receives from outside its checked code, including HTTP responses, request bodies, webhook payloads, database rows through untyped clients, localStorage, URL parameters, environment variables, file contents, and messages from workers or iframes.

Everything on that list shares one property. The value's shape is decided by something other than this program's source code. A server can deploy a new response format tonight; your annotation from last month does not participate. Validation at the boundary is how the program notices, with a clear error instead of a distant TypeError.

Validate incoming data before passing it to application code with typed parameters and objects. You usually do not need to repeat the same check in every function, unless the data can change or comes back from another untrusted source.

Pitfalls and Debugging

'data' is of type 'unknown'. Working as intended: the value has not been narrowed yet. The fix is a guard or validation call before the access, not an as. If the error appears deep inside the app, the real problem is that the boundary check is missing further out.

A crash points far from its cause. A TypeError on a supposedly typed value usually means an assertion or an any upstream let an unvalidated shape in. Search the value's path for as and any; the first one is the prime suspect, though a lying type guard, stale data, or a wrong declaration file can also be the origin.

A guard that lies. A predicate that checks two of five fields still narrows to the full type, and the unchecked fields crash later. When adding a field to a type, update its guard in the same change; the compiler does not connect the two for you.

Conversion of type 'X' to type 'Y' may be a mistake. The compiler pushing back on an assertion between unrelated types. The documented escape is asserting through unknown, but treat wanting it as a signal: check whether you need a guard or a correction to the original type instead.

An any you never wrote. Older browser APIs and JSON.parse inject any silently, while importing a package with no declarations at all reports TS7016 under noImplicitAny. What no flag catches is an explicit any inside a declaration file that does exist. Wrap such calls in small typed helpers that return unknown or a validated type, so unchecked data does not spread through the application.

Frequently Asked Questions

Why does JSON.parse return any in TypeScript?

TypeScript declares the return type of JSON.parse as any, so parsed JSON bypasses type checks unless you narrow or validate it. You can restore safety locally by wrapping it in a helper that returns unknown, which forces every caller to validate before use.

When is any appropriate in TypeScript?

TypeScript any can be useful while migrating untyped JavaScript or adapting a library without usable types. Keep unchecked values inside small helpers rather than exposing any throughout your application.

Do TypeScript projects need a runtime validation library?

TypeScript projects do not need a validation library for one or two small data shapes; hand-written guards can handle those. Once you validate many shapes or need error messages describing what failed, a schema library earns its place, and it can usually infer the static types from the schema so shape and type cannot drift apart.

Is satisfies safer than as in TypeScript?

TypeScript satisfies checks compatibility with a type instead of overriding the inferred type as an assertion can. It catches more mistakes when checking values you construct, but any and nested assertions can still hide errors. It checks values you construct at compile time; it does not validate runtime data either.

Sources

  1. [1]
    Narrowing
    (typescriptlang.org)
  2. [2]
    Everyday Types
    (typescriptlang.org)
  3. [3]
    TypeScript 3.0 Release Notes
    (typescriptlang.org)