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.
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:
Validating JSON data: If we have a schema definition for data, we can use tools to check that the data is correct. One issue with data can also be fixed automatically: We can specify default values that can be used to add properties that are missing.
Documenting JSON data formats: On one hand, the core schema definitions can be considered documentation. But JSON schema additionally supports descriptions, deprecation notes, comments, examples, and more. These mechanisms are called annotations. They are not used for validation, but for documentation.
package.json
files is completely based on a 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
}
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.
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.
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);
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);
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);
}
“Standard Schema is a common interface designed to be implemented by JavaScript and TypeScript schema libraries.” Inspired by Zod, supported by many libraries.
Approach: Converting TypeScript types to JSON schema. Libraries:
Approach: Converting a JSON schema to TypeScript types. Libraries:
Approach: A builder API creates both TypeScript types and JSON schemas. Library:
Approach: Validating JSON data via JSON schemas. This functionality is also useful for the other approaches. npm package:
Which approach and therefore library to use, depends on what we need:
If we are starting with TypeScript types and want to ensure that data (coming from configuration files, etc.) fits those types, then builder APIs that support static types are a good choice.
If our starting point is a JSON schema, then we should consider one of the libraries that support JSON schemas.
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.
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'])
);
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).
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
.
I see two options for improving data validation libraries in the long run:
Built-in support for runtime type representations in TypeScript: That would help validation libraries – at least with simpler use cases. For advanced use cases, it may be possible to leverage decorators.
Built-in support for compiling types to validation code. This could look similar to macros as supported, e.g., by Rust.
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: