TypeScript unknown and user-defined type guards
Validate parsed JSON at run time and let the compiler trust the result only after the check.
On this page
TypeScript interfaces and union types.
- Validate parsed JSON at run time and let the compiler trust the result only after the check.
- Predict the sample output and explain the state changes.
- Complete the practice task and check the boundary cases.
The concept
JSON.parse returns any, which switches off type checking. A safer habit is to treat external data as unknown. A value of type unknown cannot be used until it is narrowed, so the compiler forces you to check it first. typeof, instanceof, and in checks narrow it step by step.
A user-defined type guard is a function that returns value is User. Inside it you do the real checks, and the compiler trusts the answer afterwards. The guard is only as good as its checks: types are erased at run time, so a guard that returns true too easily is a hole in the type system. Libraries such as Zod generate such guards from a schema.
Code example
interface User {
name: string;
age: number;
}
function isUser(value: unknown): value is User {
if (typeof value !== "object" || value === null) {
return false;
}
const record = value as Record<string, unknown>;
return typeof record.name === "string" && typeof record.age === "number";
}
function describe(json: string): string {
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch {
return "invalid JSON";
}
return isUser(parsed) ? parsed.name + " is " + parsed.age : "invalid shape";
}
console.log(describe('{"name":"Ada","age":36}'));
console.log(describe('{"name":"Bo"}'));
console.log(describe("not json"));
console.log(describe("null"));Ada is 36 invalid shape invalid JSON invalid shape
Follow the code
Open the walkthrough to inspect the example one step at a time.
Show guided walkthrough
interface User { name: string; age: number;} function isUser(value: unknown): value is User { if (typeof value !== "object" || value === null) { return false; } const record = value as Record<string, unknown>; return typeof record.name === "string" && typeof record.age === "number";} function describe(json: string): string { let parsed: unknown; try { parsed = JSON.parse(json); } catch { return "invalid JSON"; } return isUser(parsed) ? parsed.name + " is " + parsed.age : "invalid shape";} console.log(describe('{"name":"Ada","age":36}'));console.log(describe('{"name":"Bo"}'));console.log(describe("not json"));console.log(describe("null"));Follow the example
Start to inspect the source, values, and output. These prepared steps explain the published sample; grouped loop steps describe multiple iterations.
View the complete dry-run table
| Step / line | What happens | State after step | Output so far |
|---|---|---|---|
| The return type value is User promises the compiler that a true result means a User. | — | — | |
| The real checks happen at run time, one property at a time. | — | — | |
| Assign the parsed result to a variable of type unknown, not any. | — | — | |
| Only inside the true branch does the compiler allow parsed.name and parsed.age. | — | — | |
| Both properties exist with the right types. | — | Ada is 36 | |
| age is missing, so the check fails. | — | Ada is 36 invalid shape | |
| JSON.parse throws and the catch block reports it. | — | Ada is 36 invalid shape invalid JSON | |
| null is valid JSON but not an object, and typeof null is "object", which is why isUser checks it explicitly. | — | Ada is 36 invalid shape invalid JSON invalid shape |
Behind the scenes
How TypeScript processes this code
TypeScript checks types and emits JavaScript. Type annotations are removed before execution.
Cost and common mistakes
O(1) for the guard (a fixed number of property checks); parsing is O(n) for n characters.
- Using any for parsed data hides bugs. unknown makes the compiler insist on a check.
- A type assertion like data as User does no checking. It only silences the compiler.
- The guard does not check extra properties or deeper nesting. Make it as strict as the program needs.
Try it yourself
How would you also require age to be a non-negative integer?
Reveal answer and reasoning
Add Number.isInteger(record.age) && record.age >= 0 to the guard, after narrowing record.age to a number.