TypeScript · Course navigation

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
Before you start

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

TypeScript
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"));
Output
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
Guided walkthroughReady
1interface User {
2 name: string;
3 age: number;
4}
5
6function isUser(value: unknown): value is User {
7 if (typeof value !== "object" || value === null) {
8 return false;
9 }
10 const record = value as Record<string, unknown>;
11 return typeof record.name === "string" && typeof record.age === "number";
12}
13
14function describe(json: string): string {
15 let parsed: unknown;
16 try {
17 parsed = JSON.parse(json);
18 } catch {
19 return "invalid JSON";
20 }
21 return isUser(parsed) ? parsed.name + " is " + parsed.age : "invalid shape";
22}
23
24console.log(describe('{"name":"Ada","age":36}'));
25console.log(describe('{"name":"Bo"}'));
26console.log(describe("not json"));
27console.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
Prepared trace of the published program. Some rows group repeated iterations.
Step / lineWhat happensState after stepOutput 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.

Further reading