Build Your Own Validator
Understand and build a zod-like validator
Sometimes you end up writing code like this:
// for /users route - create a new user
export async function POST(request: Request) {
const body = await request.json();
}
The problem here is that body is of any type, but needs to be of a specific type:
interface CreateUser {
username: string;
password: string;
email: string;
}
We could validate that with an ad-hoc function:
function validateCreateUser(user: unknown): user is CreateUser {
if (typeof user !== "object" || user === null || user === undefined) {
return false;
}
if (!(
"usename" in user
&& "password" in user
&& "email" in user
)) {
return false
}
return (
typeof user["username"] === "string"
&& typeof user["password"] === "string"
&& typeof user["email"] === "string"
)
}
If you’re not familiar with the “argument is type” syntax in the return type, it essentially is just an alias for a boolean that typescript will interpret to mean you have validated the argument as that provided type.
This works, but doing this for every single route is inelegant and messy. We can do better. Let’s go to another file and create a general validation scheme:
We define our first type inside of a namespace:
export namespace v {
export type Validator<T> = (o: unknown) => o is T
}
This syntax can be a bit confusing. Think of T as a “type parameter” for any type. We can then write validators for strings booleans, etc:
export namespace v {
export type Validator<T> = (o: unknown) => o is T
export function bool(): Validator<boolean> {
return (o: unknown) => typeof o === "boolean";
}
export function number(): Validator<number> {
return (o: unknown) => typeof o === "number";
}
export function string(): Validator<string> {
return (o: unknown) => typeof o === "string";
}
export function symbol(): Validator<symbol> {
return (o: unknown) => typeof o === "symbol";
}
}
We can make some helper types and functions for these validators:
export type Infer<K> = K extends Validator<infer T> ? T : never;
export function parse<T>(object: unknown, validator: Validator<T>): T {
const valid = validator(object);
if (!valid) {
throw new Error("Validation failed!");
}
return object;
}
“Infer” uses a type level conditional as well as the typescript infer keyword to extract the type from our schema.
We can even make compound types for nullables, optionals, and most importantly objects:
export function optional<T>(validator: Validator<T>): Validator<T | undefined> {
return (o: unknown) => validator(o) || o === undefined;
}
export function nullable<T>(validator: Validator<T>): Validator<T | null> {
return (o: unknown) => validator(o) || o === null;
}
export function obj<T extends Record<string, Validator<any>>>(obj: T): Validator<{ [K in keyof T]: Infer<T[K]> }> {
return (o: unknown): o is { [K in keyof T]: Infer<T[K]> } => {
if (typeof o !== "object" || o === null || o === undefined) {
return false;
}
const target = o as Record<string, unknown>;
for (const key of Object.keys(obj)) {
const validator = obj[key]!;
const valid = validator(target[key]);
if (!valid) {
return false;
}
}
return true;
}
}
The way we do an object can be a bit confusing if you’re unfamiliar with type level programming. We have to use something called a mapped type, which you can think of as a type level for loop. The full code for this is up on my website if you’d like to study or copy it.
We can now write our createUser schema again, but this time with our new validation:
import { v } from "./validation";
export const createUserSchema = v.object({
username: v.string(),
password: v.string(),
email: v.string(),
});
export type CreateUser = v.Infer<typeof createUserSchema>;
Notice that CreateUser
is still inferred to be exactly what we had before. We now need to make a function to parse:
export function parse<T>(object: unknown, validator: Validator<T>): T | null {
const valid = validator(object);
if (!valid) {
throw new Error("Validation Error! (in production you may want to be more specific and return a 4XX error)")
}
return object;
}
Now we can go back to our original route,
export async function POST(request: Request) {
const body = await request.json();
const user = v.parse(body, createUserSchema);
// ... do whatever else you need to do with a typed and validated user object
}
To be honest, it’s better to use a library like zod or typebox for this rather than build your own. These provide deeper error handling and the ability to validate correct email and password formats, which our earlier example does not handle. Regardless, understanding at least the basics of how they are implemented is the difference between a wizard and a mere magic user.