description | last_modified |
---|---|
An overview of ways to add runtime type checking to TypeScript applications |
2022-01-31 14:29:20 UTC |
- Why additional type checking?
- What about type guards?
- Strictness of runtime checking
- Runtime type checking strategies
TypeScript only performs static type checking at compile time! The generated JavaScript, which is what actually runs when you run your code, does not know anything about the types.
- Works fine for type checking within your codebase
- Doesn’t provide any kind of protection against malformed external input
- Isn't designed to express typical input validation constraints (minimum array length, string matching a certain pattern) that are about more than simple type safety
- Several of the methods below provide an easy way to specify these kinds of constraints together with the actual TypeScript types
Type guards are a way to provide information to the TypeScript compiler by having the code check values at runtime.
- Some degree of runtime type checking
- Often, type guards combine information available at runtime with information from type declarations specified in the code. The compiler will make incorrect assumptions if the actual input doesn't match those type declarations.
See also Type guards
- Needs to be at least as strict as compile-time checking (otherwise, we lose the guarantees that the compile-time checking provides)
- Can be more strict if desired (require age to be >= 0, require string to match a regex)
- Note that the TypeScript compiler will not be able to rely on such information
- Flexible
- Can be tedious and error-prone
- Can easily get out of sync with actual code
Example validation library: joi
import Joi from "joi"
const schema = Joi.object({
firstName: Joi.string().required(),
lastName: Joi.string().required(),
age: Joi.number().integer().min(0).required(),
});
- Flexible
- Easy to write
- Can easily get out of sync with actual code
Note: there are some very similar libraries than can derive TypeScript types from the runtime type definitions (see below)
Example JSON Schema:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"firstName",
"lastName",
"age"
],
"properties": {
"firstName": {
"type": "string"
},
"lastName": {
"type": "string"
},
"age": {
"type": "integer",
"minimum": 0
}
}
}
- Standard format, lots of libraries available for validation ...
- Example library for validation: ajv
- JSON: easy to store and share
- Can become very verbose and can be tedious to create by hand
- Can easily get out of sync with actual code
Most robust library at the moment: ts-json-schema-generator (for some alternatives, see this discussion)
Example input, including specific constraints that are stricter than TS type checking:
interface Person {
/** @pattern ^[A-Z][a-z]+$ */
firstName: string;
lastName: string;
/**
* @asType integer
* @minimum 0
*/
age: number;
}
Example output (with default options):
{
"$ref": "#/definitions/Person",
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"Person": {
"additionalProperties": false,
"properties": {
"age": {
"minimum": 0,
"type": "integer"
},
"firstName": {
"pattern": "^[A-Z][a-z]+$",
"type": "string"
},
"lastName": {
"type": "string"
}
},
"required": [
"firstName",
"lastName",
"age"
],
"type": "object"
}
}
}
Benefits compared to manually creating JSON Schemas:
- Single source of truth
- You can use scripts to make sure generated schemas and code stay in sync
For some of the runtime type checking libraries mentioned in this note, it's possible to automatically generate JSON Schemas based on the defined runtime types. Some examples:
- The joi library (see Manual checks using a validation library)
- Can use joi-to-json
- The zod library (see Deriving static types from runtime types)
- Can use zod-to-json-schema
- Bonus points because it allows deriving static types from runtime types
- The @sinclair/typebox library (see Deriving static types from runtime types)
- JSON Schema is immediately generated in the background
- Runtime type checking is implemented through generated JSON Schema
- Bonus points because it allows deriving static types from runtime types
- The class-validator library (see Decorator-based class validation)
- Can use class-validator-jsonschema
- Bonus points because it integrates static and runtime type checking
Example library: ts-runtime
- Processes code
- Transpiles code into equivalent code with built-in runtime type checking
Example code:
interface Person {
firstName: string;
lastName: string;
age: number;
}
const test: Person = {
firstName: "Foo",
lastName: "Bar",
age: 55
}
Example transpiled code
import t from "ts-runtime/lib";
const Person = t.type(
"Person",
t.object(
t.property("firstName", t.string()),
t.property("lastName", t.string()),
t.property("age", t.number())
)
);
const test = t.ref(Person).assert({
firstName: "Foo",
lastName: "Bar",
age: 55
});
Problem: no control over where type checking happens (we only need runtime type checks at the boundaries!)
Note: Library is still in an experimental stage and not recommended for production use!
Example library: yup
- You define runtime types
- TypeScript infers the corresponding static types from these
Example runtime type:
import { object, string, number, InferType } from "yup";
const personSchema = object({
firstName: string().required(),
lastName: string().required(),
age: number().integer().positive().required(),
});
Extracting the corresponding static TypeScript type:
type Person = InferType<typeof personSchema>;
Equivalent to:
type Person = {
firstName: string;
lastName: string;
age: number;
}
Benefits/drawbacks:
- No possibility for types to get out of sync
- You need to define your types as
yup
runtime types, not ideal if you want to validate input against a class definition- One way to handle this: define a
yup
type matching the class, create an interface based on the type alias obtained fromyup
and then make the class implement the interface. This way, TypeScript helps you to keep theyup
type in sync with the class, although not all cases are covered (for example, you still need to remember to update theyup
type when adding properties to the class). - Probably, decorator-based class validation is a better approach in this case (see below)
- One way to handle this: define a
- Harder to share static types (e.g. between backend and frontend) because they are inferred from
yup
types
Some alternative libraries (compare their popularity):
- The ow library
- The io-ts library
- Built on fp-ts, a library for typed functional programming in TypeScript
- Can be confusing if you're not familiar with functional programming concepts
- Provides more strict static type checking than standard TypeScript
- For example, if you define a property
age
that must be an integer, the inferred TypeScript type will not haveage: number
but instead it will haveage: t.Branded<number, t.IntBrand>
. Using a value of that type is straightforward, since you can use it anywhere you can use anumber
. In order to obtain a value of the type, you must either pass through the runtime type checking (recommended) or bypass TypeScript type checking altogether with something likeconst age: t.Branded<number, t.IntBrand> = 1 as any
(might make sense for test data and hardcoded values). - The extra type safety may or may not be worth the extra boilerplate and complexity for your use case
- For example, if you define a property
- The zod library
- The runtypes library
- The @sinclair/typebox library
- Generates in-memory JSON Schemas in the background
- You need another library (like ajv) for the actual validation
Example library: class-validator
- Uses decorators on class properties
- Very similar to Java’s JSR-380 Bean Validation 2.0 (implemented by, for example, Hibernate Validator)
- Part of a family of Java EE-like libraries that also includes typeorm (ORM, similar to Java’s JPA) and routing-controllers (similar to Java’s JAX-RS for defining APIs)
Example code:
import { plainToClass } from "class-transformer";
import {
validate, IsString, IsInt, Min
} from "class-validator";
class Person {
@IsString()
firstName: string;
@IsString()
lastName: string;
@IsInt()
@Min(0)
age: number;
}
const input: any = {
firstName: "Foo",
age: -1
};
const inputAsClassInstance = plainToClass(
Person, input as Person
);
validate(inputAsClassInstance).then(errors => {
// handle errors if needed
});
Benefits/drawbacks:
- No possibility for types to get out of sync
- Good for validating against a class definition
- Not ideal if you want to validate input against an interface
- One way to handle this: define a class implementing the interface and add the decorators there. This way, TypeScript helps you to keep the class in sync with the interface, although not all cases are covered (for example, you still need to remember to update the class type when removing properties from the interface)
Note: class-validator needs actual class instances to work on
- Here, we used its sister library class-transformer to transform our plain input into an actual
Person
instance- The transformation in itself does not perform any kind of type checking!
Some example frameworks:
- The NestJS framework
- Integrates with class-validator for runtime type checking (doc)
- The tsoa framework
- Automatically generates OpenAPI specs from your code
- Uses JSON Schema under the hood to provide runtime type checking
- The type-graphql framework
- Automatically generates GraphQL SDL from your code
- Integrates with class-validator for runtime type checking (doc)
- The routing-controllers framework
- Integrates with class-validator for runtime type checking