unknown, any, and Runtime Data in TypeScript
Every TypeScript app has edges where data arrives from outside: an API response, a form post, a file, an environment variable. The type system cannot see across those edges, and the two types that describe that ignorance, any and unknown, behave completely differently. Choosing the right one is most of the battle.
This is the last guide in the types topic, and it leans on the narrowing techniques from union types and narrowing.
What any Actually Does
The any type does not mean "any value". It means "stop checking this value". Every operation on an any is permitted, every property exists, and whatever it touches becomes unchecked too:
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. That is the contagion problem: any is assignable to everything except never, so one unchecked value quietly launders itself into typed variables across the codebase.
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 unknown Beats any at Boundaries
unknown is the honest version of the same idea. It also accepts every value, but it permits nothing until you narrow it:
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'. The difference in one sentence: any moves the failure to runtime, 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. The principle is unchanged either way: parse the outside world once, at the edge, then let types carry the result inward.
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.
The rule that survives code review: an as pointing at data that crossed a runtime boundary is a bug waiting for its moment. Reach for a guard instead.
Where the Boundaries Are
Boundary thinking only works if you can spot the boundaries. The usual list: 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.
Inside the fence, validated data needs no re-checking, and adding defensive guards to every function is noise. Check once at the edge, type the result, and let the compiler do the escort work inward.
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: nine times out of ten the honest fix is a guard, or admitting the type you started from was wrong.
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 the contagion stops at one file.
Frequently Asked Questions
Why does JSON.parse return any instead of unknown?
History. JSON.parse was typed before unknown existed, and changing the built-in signature now would break enormous amounts of existing code. You can restore safety locally by wrapping it in a helper that returns unknown, which forces every caller to validate before use.
Is any ever the right choice?
Occasionally. It has legitimate uses at the edge of untyped JavaScript libraries, in migration code that is being typed incrementally, and in some generic library internals. The test is containment: an any that leaks into signatures spreads unchecked code everywhere it flows.
Do you need a validation library?
Not for one or two small shapes, where hand-written guards are fine. 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?
Yes, for the job it does. satisfies checks that a value conforms to a type while keeping its narrower inferred type, though an any-typed expression satisfies anything and inner assertions can hide a mismatch, so it is evidence rather than proof of conformance. It checks values you construct at compile time; it does not validate runtime data either.
Related
- TypeScript Types for the full topic overview
- Union Types and Narrowing for the narrowing machinery guards build on
- The First Types to Learn for the annotations validated data flows into
- TypeScript for the language guide
Sources
-
[1]
Narrowing(typescriptlang.org)
-
[2]
Everyday Types(typescriptlang.org)
-
[3]
TypeScript 3.0 Release Notes(typescriptlang.org)
Read Next
Union types, literal types, and the narrowing toolkit: typeof, in, instanceof, discriminated unions, and exhaustiveness checking with never.
Primitive annotations, arrays, object types, and type inference in TypeScript: what to annotate, what to let the compiler infer, and why const and let behave differently.
The TypeScript type system from the ground up: the first annotations, interfaces and type aliases, union types and narrowing, and handling unknown runtime data.