TypeScript unknown and user-defined type guards
Validate parsed JSON at run time and let the compiler trust the result only after the check.
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
Explanation
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.
Explore the dry run
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 |
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.