Elegant Runtime Type-Checking in TypeScript classes using Zod
In the following cookbook, I want to shed light on a rather interesting way to add runtime validation to your TypeScript project. This is especially relevant to Angular, NestJS, or any other setup that utilizes OOP patterns in TypeScript. Nevertheless, you can also use it in other React-y/Functional setups if you don't mind using classes here and there.
Today's Dish: ZodDoc
ZodDoc, whose name was inspired by JSDoc/TSDoc, is a set of TypeScript Decorators that add runtime validation using the power of Zod schemas to validate the input/output of methods. It looks something like this:
export class Statistics {
// Returns the average of two numbers.
@param(0, number) // `x` - The first input number
@param(1, number) // `y` - The second input number
@returns(number) // The arithmetic mean of `x` and `y`
public static getAverage(x: number, y: number): number {
return (x + y) / 2.0;
}
}
As you might have noticed, this closely resembles the TSDoc example from its documentation, here it is for reference:
export class Statistics {
/**
* Returns the average of two numbers.
* @param x - The first input number
* @param y - The second input number
* @returns The arithmetic mean of `x` and `y`
*/
public static getAverage(x: number, y: number): number {
return (x + y) / 2.0;
}
}
Except ZodDoc uses the AOP capabilities of TypeScript decorators and the power of Zod schemas to validate both the parameters of the method and the return type against a Zod schema.
But you might wonder, where is Zod in there? and where did that number
parameter of the decorators came from?
Spot On! the number
parameter is, in fact, the Zod schema we are validating against. The omitted import
statement would look like:
import { number } from "zod";
Ingredients
Before we start cooking, make sure you have all the following ingredients in place:
Zod
Zod is a popular schema declaration and validation library that offers a complete range of features, from primitives to constructing your custom schema, to customizing error handling, to TypeScript inference, and more. Install it via npm with the following command: npm i zod
, and check out their documentation if you need to learn more about the library.
TypeScript Decorators
If you are planning to integrate ZodDoc into an Angular/NestJS application, you are good to go, as these frameworks already use TypeScript Decorators everywhere. But if your setup does not support decorators, you might need to opt-in by adding the following to your tsconfig.json
file:
{
"compilerOptions": {
"target": "ES5",
"experimentalDecorators": true
}
}
Let's start cooking
Let's start by a simple declaration of the returns
decorator
import { z } from "zod";
export function returns<T>(schema: z.Schema<T>) {
return function(
_target: Object,
_propertyKey: string | symbol,
descriptor: TypedPropertyDescriptor<(...args: any[]) => T>) {
// TODO: Implement logic
}
}
We have here a standard method decorator declaration wrapped with another function that takes a Zod schema as a parameter. Using the generic argument T
we bind the expected type of the Zod schema z.Schema<T>
with the return type of the decorated method (...args: any[]) => T
which provide us with compile-time type-checking and errors when the return type and the schema type mismatch:
...
@returns(z.number()) // ๐ด Argument of type
// 'TypedPropertyDescriptor<() => string>'
// is not assignable to parameter of type
// 'TypedPropertyDescriptor<(...args: any[]) => number>'.
public doSomething(): string {
return "";
}
Cool! let's now implement the decorator logic
import { z } from "zod";
export function returns<T>(schema: z.Schema<T>) {
return function(
_target: Object,
_propertyKey: string | symbol,
descriptor: TypedPropertyDescriptor<(...args: any[]) => T>) {
if (!descriptor || !descriptor.value) {
return;
}
const originalMethod = descriptor.value;
// replace the original method with our own implementation
descriptor.value = function(...args: unknown[]): T {
// Grab the original result
const originalResult = originalMethod?.apply(this, args);
// Call the `parse` method of the supplied schema
// with the original result as parameter.
// If the original result is conforming to the schema, it
// will be returned. Otherwise, a `ZodError` will be thrown.
return schema.parse(originalResult);
};
return descriptor;
}
}
We can then use it in our classes as follows:
import { z } from "zod";
import { returns } from "...";
export class Statistics {
@returns(z.number())
public static getAverage(x: number, y: number): number {
return (x + y) / 2.0;
}
}
We can now slightly modify our decorator factory to also accept functions that returns a schema, just to make its use with primitive Zod schema more succinct, e.g. @returns(number)
instead of @returns(z.number())
.
- export function returns<T>(schema: z.Schema<T>) {
+ export function returns<T>(schema: (z.Schema<T> | () => z.Schema<T>)) {
// ...
+ if (schema instanceof Function) {
+ return schema().parse(originalResult);
+ }
return schema.parse(originalResult);
// ...
}
Here you go! you can now use it as shown in the first example, or using you own custom schema:
src/.../UserResponse.ts
import { z } from "zod";
// Define you schema (source of truth)
export const UserResponse = z.object({
id: z.string().uuid(),
name: z.string().min(2),
sex: z.enum(["male", "female"]),
birthdate: z.date(),
});
// Infer the TypeScript type from the schema
// Yes, you can give both the same name, it would still work
export type UserResponse = z.infer<typeof UserResponse>;
src/.../UserService.ts
import { UserResponse } from "./UserResponse";
import { promise } from "zod";
import { returns } from "...";
import { httpClient } from "...";
export class UserService {
@returns(promise(UserResponse))
public async getAuthUser(): Promise<UserResponse> {
return await httpClient.get<UserResponse>("auth/me");
}
}
That's it from me, I'll leave you figure out how to implement the param
decorator on your own. You have all the tools you need; it shouldn't be difficult ๐.