TypeScript’s unknown and any can both appear when data enters an application without a known shape. But they behave very differently: any lets you use a value without checking it, while unknown makes you establish what the value is first. This guide shows how to use unknown at an API boundary, validate a JSON payload with type guards, and handle invalid responses safely.
Key Takeaways
- TypeScript types are removed when code is compiled; they do not validate JSON received at runtime.
- Prefer
unknownfor data whose shape has not been checked. It requires narrowing before use. - Validate the actual fields your application depends on, then use a type guard to communicate the result to TypeScript.
- Handle HTTP failures, malformed JSON, and valid JSON with an unexpected shape as distinct failure cases.
- A type assertion such as
payload as Userchanges what TypeScript permits; it does not check the payload.
Why unknown is safer than any
Both types can represent values of any shape, but they place different responsibilities on your code.
| Type | Can assign any value to it? | Can use it without narrowing? | Best use |
|---|---|---|---|
any | Yes | Yes | Legacy code or deliberate escape hatches |
unknown | Yes | No | Untrusted data before validation |
With any, an incorrect assumption can pass type checking:
interface User {
id: string;
name: string;
}
declare const payload: any;
const user: User = payload;
console.log(user.name.toUpperCase());
This code compiles, but payload could be null, a string, or an object with no name. The error appears only when the code runs.
With unknown, TypeScript requires a check before the value can be treated as a User:
declare const payload: unknown;
if (typeof payload === "string") {
console.log(payload.toUpperCase());
}
That check narrows the type inside the if block. Other useful narrowing tools include typeof, equality checks, Array.isArray, the in operator, and user-defined type guards.
TypeScript types do not validate API responses
TypeScript checks relationships between types while developing and compiling code. It does not insert runtime checks that confirm a value from a network response matches an interface. JSON is received as runtime data, and a type declaration alone cannot make that data trustworthy.
For example, this assertion is not validation:
const user = (await response.json()) as User;
It tells the compiler to treat the result as User. It does not check whether the response contains a string id or name. The same problem applies to a generic helper that merely asserts its result:
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url);
return (await response.json()) as T;
}
The generic signature may look safe, but this implementation performs no check that the returned JSON is actually T. Keep data untrusted until runtime validation succeeds.
Validate an API response with a type guard
The following example checks a small user payload. It accepts only a non-null, non-array object with a string id, a string name, and an array of string roles.
interface User {
id: string;
name: string;
roles: string[];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isUser(value: unknown): value is User {
return (
isRecord(value) &&
typeof value.id === "string" &&
typeof value.name === "string" &&
Array.isArray(value.roles) &&
value.roles.every((role: unknown) => typeof role === "string")
);
}
isRecord rules out null and arrays before the code checks object properties. Its return type tells TypeScript that a successful check provides string-keyed properties whose values still need validation. isUser checks each required field and narrows the value to User only when every condition passes.
Use the guard at the point where untrusted data enters the application:
async function fetchUser(url: string): Promise<User> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`User request failed with HTTP ${response.status}`);
}
// Assigning to unknown keeps the response untrusted even if the
// TypeScript library declaration for response.json() returns any.
const payload: unknown = await response.json();
if (!isUser(payload)) {
throw new Error("User API returned an invalid payload");
}
return payload;
}
The function handles an unsuccessful HTTP status separately from a payload whose shape is wrong. response.json() can also reject—for example, if the response body is not valid JSON—so callers should handle that failure too:
try {
const user = await fetchUser("https://api.example.test/users/42");
console.log(user.name);
} catch (error) {
if (error instanceof Error) {
console.error("Could not load user:", error.message);
} else {
console.error("Could not load user:", error);
}
}
Replace the example URL with an endpoint your application is authorized to call. In a real application, you might use distinct error classes or return a result type so callers can distinguish network failures, HTTP errors, malformed JSON, and validation failures. The important point is not to turn any of those failures into a trusted User.
Choose checks that match the contract
A type guard should reflect the data your application actually requires. The example checks all three required fields, including every role. If the API contract allows roles to be absent, update the User type and guard to model that explicitly. Do not silently accept missing or malformed values just to make the guard pass.
For numeric fields, consider whether any number is valid. For example, if the application requires a finite, non-negative amount, a useful check is typeof value.amount === "number" && Number.isFinite(value.amount) && value.amount >= 0. Checking only typeof would accept values such as NaN and negative numbers.
For larger or changing schemas, a runtime validation library can reduce repetitive checks and provide useful error details. The key requirement remains the same: use a runtime parser or validator that examines the value. A TypeScript type annotation or generic parameter by itself is not a runtime validator.
Common Mistakes
- Using
anyat the API boundary: It lets unchecked data flow through the program. Preferunknownuntil validation succeeds. - Treating an assertion as a check:
payload as Userdoes not inspectpayload. - Checking only that the value is an object:
nullis an object in JavaScript, arrays are objects too, and an object may still have missing or incorrectly typed fields. - Validating only the fields you happen to read today: If application behavior depends on another field, validate that field too. Keep the runtime check aligned with the data contract.
- Ignoring failed HTTP responses:
fetchdoes not make a non-success HTTP status a validated application result. Checkresponse.okbefore trusting the body. - Assuming valid JSON is valid application data: Parsing confirms JSON syntax, not that the parsed value has the shape your code expects.
Best Practices
- Treat network responses, parsed JSON, and other external inputs as
unknown. - Check the HTTP status before treating a response body as a successful result.
- Validate required fields and meaningful constraints at the boundary where data enters your code.
- Return a trusted application type only after runtime validation passes.
- Decide how callers should respond to network, HTTP, parsing, and validation errors; do not silently replace invalid data with a plausible default.
- Keep validation logic close to the API boundary or centralize it in a clearly named parser, so unchecked values do not spread through the application.
Conclusion
Use unknown when a value may have any shape but has not yet been checked. Narrow it with runtime checks before using it as a concrete type. That distinction makes TypeScript more useful at API boundaries, where compile-time types cannot verify the data arriving at runtime.
