4 min read

Elegant Runtime Type-Checking in TypeScript classes using Zod

TypeScript types disappear at runtime. If you work with classes in Angular, NestJS, or any other TypeScript codebase that leans on OOP, that can leave an important gap at method boundaries.

This pattern uses decorators and Zod to validate method inputs and return values at runtime while keeping the TypeScript types aligned.

ZodDoc

ZodDoc is a small set of TypeScript decorators that use Zod schemas to validate method parameters and return values. It looks 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;
    }

}

The number argument passed to @param and @returns is the Zod schema itself. The omitted import looks like this:

import { number } from "zod";

Requirements

Zod

Install Zod with npm i zod. If you are not already using it, the official documentation covers the schema API in detail.

TypeScript Decorators

Angular and NestJS already rely on decorators, so this usually works out of the box there. In other setups, you may need to enable decorators in tsconfig.json:

{
  "compilerOptions": {
    "target": "ES5",
    "experimentalDecorators": true
  }
}

Implementing returns

Start with a simple 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
        }
}

This is a standard method decorator factory. The generic T ties the Zod schema type z.Schema<T> to the decorated method type (...args: any[]) => T, so TypeScript can catch mismatches at compile time:

...
@returns(z.number()) // 🔴 Argument of type
                     // 'TypedPropertyDescriptor<() => string>'
                     // is not assignable to parameter of type
                     // 'TypedPropertyDescriptor<(...args: any[]) => number>'.
public doSomething(): string {
    return "";
}

Now implement the runtime check:

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;

        }
}

The decorator replaces the original method, calls it, and validates the returned value with schema.parse. If the value does not match, Zod throws a ZodError.

You can then use it like this:

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;
    }
}

You can also accept a function that returns a schema. That makes primitives slightly nicer to write, for example @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);
     // ...
 }

This also works with custom schemas:

src/.../UserResponse.ts

import { z } from "zod";

// Define the schema once.
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.
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");
    }

}

The same approach can be used to implement @param as well: capture the original method, validate the targeted argument before calling it, and then delegate to the original implementation.