HomepageExploring TypeScript (TS 5.8 Edition)
You can support this book: buy it or donate
(Ad, please don’t block.)

32 Validating external data

Data validation means ensuring that data has the desired structure and content.

With TypeScript, validation becomes relevant when we receive external data such as:

In these cases, we expect the data to fit static types we have, but we can’t be sure. Contrast that with data we create ourselves, where TypeScript continuously checks that everything is correct.

This chapter explains how to validate external data in TypeScript.

32.1 JSON schema

Before we can explore approaches for data validation in TypeScript, we need to take a look at JSON schema because several of the approaches are based on it.

The idea behind JSON schema is to express the schema (structure and content, think static type) of JSON data in JSON. That is, metadata is expressed in the same format as data.

The use cases for JSON schema are:

32.1.1 An example JSON schema

This example is taken from the json-schema.org website:

{
  "$id": "https://example.com/geographical-location.schema.json",
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Longitude and Latitude Values",
  "description": "A geographical coordinate.",
  "required": [ "latitude", "longitude" ],
  "type": "object",
  "properties": {
    "latitude": {
      "type": "number",
      "minimum": -90,
      "maximum": 90
    },
    "longitude": {
      "type": "number",
      "minimum": -180,
      "maximum": 180
    }
  }
}

The following JSON data is valid w.r.t. this schema:

{
  "latitude": 48.858093,
  "longitude": 2.294694
}

32.2 Approaches for data validation in TypeScript

This section provides a brief overview of various approaches for validating data in TypeScript. For each approach, I list one or more libraries that support the approach. W.r.t. libraries, I don’t intend to be comprehensive because things change quickly in this space.

32.2.1 Approaches not using JSON schema

One approach for validation is to create a schema object by invoking builder methods and functions. Such an object enables us to:

There are many libraries that work like this. Next, we’ll look at a few examples.

32.2.1.1 Zod

Zod (demonstrated in more depth later in this chapter) uses builder methods and is very popular.

import { z } from 'zod';

// Define schema
const ProductSchema = z.object({
  name: z.string().min(1), // non-empty
  scores: z.array(
    z.union([ z.number(), z.string() ])
  ),
});

// Derive TypeScript type from schema
type Product = z.infer<typeof ProductSchema>;
type _ = Assert<Equal<
  Product,
  {
    name: string,
    scores: Array<number | string>,
  }
>>;

// Validate data
const product = ProductSchema.parse({
  name: 'Toothpaste',
  scores: [ 5, '*****' ],
});
assertType<Product>(product);
32.2.1.2 Valibot

Valibot is similar to Zod but uses functions, which helps with excluding unused code from bundles.

import * as v from 'valibot';

// Define schema
const ProductSchema = v.object({
  name: v.pipe(v.string(), v.minLength(1)), // non-empty
  scores: v.array(
    v.union([ v.number(), v.string() ])
  ),
});

// Derive TypeScript type from schema
type Product = v.InferOutput<typeof ProductSchema>;
type _ = Assert<Equal<
  Product,
  {
    name: string,
    scores: Array<number | string>,
  }
>>;

// Validate data
const product = v.parse(ProductSchema, {
  name: 'Toothpaste',
  scores: [ 5, '*****' ],
});
assertType<Product>(product);
32.2.1.3 ArkType

ArkType has a distinct way of specifying types: Instead of function or method invocations, it often uses string literal types (parsed via template literal types at compile time).

import { type } from 'arktype';

// Define schema
const productSchema = type({
  name: 'string > 0', // non-empty
  scores: '(number | string)[]',
});

// Derive TypeScript type from schema
type Product = typeof productSchema.infer;
type _ = Assert<Equal<
  Product,
  {
    name: string,
    scores: Array<number | string>,
  }
>>;

// Validate data
const product = productSchema({
  name: 'Toothpaste',
  scores: [ 5, '*****' ],
});
if (product instanceof type.errors) {
  throw new TypeError(product.summary);
} else {
  assertType<Product>(product);
}
32.2.1.4 Standard Schema: standard for validation APIs

Standard Schema is a common interface designed to be implemented by JavaScript and TypeScript schema libraries.” Inspired by Zod, supported by many libraries.

32.2.2 Approaches using JSON schema

32.2.3 Picking a library

Which approach and therefore library to use, depends on what we need:

32.3 Example: validating data via the library Zod

32.3.1 Defining a “schema” via Zod’s builder API

Zod has a builder API that produces both types and validation functions. That API is used as follows:

import * as z from 'zod';

const FileEntryInputSchema = z.union([
  z.string(),
  z.tuple([z.string(), z.string(), z.array(z.string())]),
  z.interface({
    file: z.string(),
    'author?': z.string(),
    'tags?': z.array(z.string()),
  }),
]);

Note: z.interface() and the property keys with question marks are Zod 4 features. A subsection below explains why they are used here.

For larger schemas, it can make sense to break things up into multiple const declarations.

Zod can produce a static type from FileEntryInputSchema, but I decided to (redundantly!) manually maintain the static type FileEntryInput:

type FileEntryInput =
  | string
  | [ string, string, Array<string> ]
  | { file: string, author?: string, tags?: Array<string> }
  ;

Why the redundancy?

We can use a static check to ensure that FileEntryInputSchema and FileEntryInput are in sync:

type _ = Assert<Equal<
  z.infer<typeof FileEntryInputSchema>,
  FileEntryInput
>>;

The generic type z.infer derives a type from a Zod schema.

32.3.2 Validating data

The schema method .parse() checks if a value has the correct structure:

const fileEntryInput: FileEntryInput = FileEntryInputSchema.parse( // OK
  ['iceland.txt', 'me', ['vacation', 'family']]
);
assert.throws(
  () => FileEntryInputSchema.parse(['iceland.txt', 'me'])
);

32.3.3 Tip: Use z.interface() and property keys with question marks for optional properties (Zod 4)

They are a Zod 4 features. Without them, optional properties have the wrong types:

const Schema = z.object({
  file: z.string(),
  author: z.string().optional(),
  tags: z.array(z.string()).optional(),
});
type _ = Assert<Equal<
  z.infer<typeof Schema>,
  {
    file: string,
    author?: string | undefined, // (A)
    tags?: Array<string> | undefined, // (B)
  }
>>;

.optional() does not do exactly what we want: In addition to making the property optional, it also adds undefined to its type (line A and line B).

32.3.4 External vs. internal representation of data

When working with external data, it’s often useful to distinguish two types.

On one hand, there is the type that describes the input data. Its structure is optimized for being easy to author:

type FileEntryInput =
  | string
  | [ string, string, Array<string> ]
  | { file: string, author?: string, tags?: Array<string> }
  ;

On the other hand, there is the type that is used in the program. Its structure is optimized for being easy to use in code:

type FileEntry = {
  file: string,
  author: null | string,
  tags: Array<string>,
};

After we have used Zod to ensure that the input data conforms to FileEntryInput, we can use a conversion function that converts the data to a value of type FileEntry.

32.4 Conclusion: various thoughts about data validation libraries

I see two options for improving data validation libraries in the long run:

For libraries that have builder APIs, I’d find tools useful that compile TypeScript types to builder API invocations (online and via a shell command). This would help in two ways: