Express.js route validation with Zod
I'm a big fan of Zod. You can create almost any schema to fit your individual needs with very little effort.
Although Zod is popular among the FE community, you can also integrate Zod with Express.js. There are a few things that you'll need to be aware of when you do that though. Depending on your use case, integrating Zod with Express will look slightly different.
Defining the Needed Types
Personally, I like to keep my schemas strict. A strict schema in Zod is a schema that disallows any extra properties not explicitly defined in the schema. Personally, I prefer if my endpoints don't accept any query parameters, body properties, params, or headers outside of what the endpoint expects, but again this is a personal preference. If your use case doesn't require strict schemas you can do everything described here with non-strict Zod objects by skipping a few steps.
With that being said, there are a few ways you can define a strict Zod object:
const strictObject = z.strictObject({...}) // ?? My preferred way
const strictObject = z.object({...}).strict()
By default, Zod doesn't export a type for a strict object so we'll need to create one as we'll need it:
import type { ZodObject, ZodRawShape } from "zod";
export type StrictZodObject = ZodObject<ZodRawShape, "strict">;
With the StrictZodObject type we can create a "master" type for our schema:
export type Schema = {
querySchema: StrictZodObject;
bodySchema: StrictZodObject;
paramsSchema: StrictZodObject;
headerSchema: StrictZodObject;
};
Notice how all of the properties are required. You might be wondering why that could be as not all endpoints will need all 4. This is because we want our endpoints to disallow any extra properties that haven't been explicitly defined in our schemas as already mentioned. This means that if our endpoint doesn't expect any query parameters for example we'll need to pass an empty strict object:
const schema = {
querySchema: z.strictObject({}), // ?? empty strict object
// the rest of the schema
}
The reason why we want to do this is because we don't want any query parameters coming in that we haven't explicitly defined and in this case there will be no query parameters. The way to disallow all query parameters from being passed is by passing an empty strict object to the schema.
Validation middleware
Now that we have the needed types and we're clear on how we want to approach the validation, it's time to create a helper function:
export const validateWithZod =
({ querySchema, bodySchema, paramsSchema, headerSchema }: Schema) =>
async (req: Request, res: Response, next: NextFunction) => {
try {
querySchema.parse(req.query);
bodySchema.parse(req.body);
paramsSchema.parse(req.params);
headerSchema.parse(req.headers); // Parsing 4 different schemas ??
return next();
} catch (err) {
const error = err instanceof ZodError ? err.errors : err;
console.error(error);
return res.status(400).json(error);
}
};
You might be wondering why are we parsing 4 different schemas, when we could have merged them into one and we would have only needed to parse one schema. Although that would have saved us a few lines of code it wouldn't have given us the desired flexibility when it comes to error messages. For each different schema failure we want a different error message. For example, for the query params schema we want the following errorMap:
querySchema.parse(req.query, {
errorMap: (error, ctx) => {
if (error.code === ZodIssueCode.unrecognized_keys) {
return { message: "Unrecognized query parameters" };
}
return { message: ctx.defaultError };
},
});
What the following code does, is it outputs an "Unrecognized query parameters" error when a query parameter that hasn't been defined in the schema gets passed. Otherwise it returns the default error message. The default error message is nothing but the error messages defined on an individual schema level. For example:
const numericString = z.string().regex(/^\d+$/, {
message: "Must be a numeric string",
});
const querySchema = z.strictObject({
offset: numericString.optional(),
limit: numericString.optional(),
});
If we send query params of "limit=ten" then the error message is going to be ctx.defaultError which will be "Must be a numeric string". If we send a query params of "search=bench" then the error code will be ZodIssueCode.unrecognized_keys and the error message will be "Unrecognized query parameters".
There is a case to be made that we can still get individual error messages with one schema by doing something like this:
// Why not do that ??
const numericString = z.string().regex(/^\d+$/, {
message: "Must be a numeric string",
});
const querySchema = z.strictObject({
offset: numericString.optional(),
limit: numericString.optional(),
}, {
errorMap: (error, ctx) => {
if (error.code === ZodIssueCode.unrecognized_keys) {
return { message: "Unrecognized query parameters" };
}
return { message: ctx.defaultError };
},
});
Although that would work fine, the error map for the query params schema on all endpoints will always be the same. That's why we'd rather keep it inside the validateWithZod function as it will only need to be defined once.
领英推荐
With that being said we'll need to pass custom error messages for the rest of the request properties as well. As you can imagine, that would look very similar so we can create a small helper function:
type parseWithSchemaType = {
data: unknown;
schema: StrictZodObject;
errorMessage: string;
};
const parseWithSchema = ({
data,
schema,
errorMessage,
}: parseWithSchemaType) => {
schema.parse(data, {
errorMap: (error, ctx) => {
if (error.code === ZodIssueCode.unrecognized_keys) {
return { message: errorMessage };
}
return { message: ctx.defaultError };
},
});
};
And then we can use the helper function inside our middleware:
export const validateWithZod =
({ querySchema, bodySchema, paramsSchema, headerSchema }: Schema) =>
async (req: Request, res: Response, next: NextFunction) => {
try {
parseWithSchema({
data: req.query,
schema: querySchema,
errorMessage: "Unrecognized query parameters",
}); // ?? using the helper function for the query params
// Remaining parsing
return next();
} catch (err) {
// error handling
}
};
Finally we can use the middleware with our routes:
const router = Router();
router
.route("/exercises")
.get(validateWithZod(schema), getExercisesRoute);
// ?? Our route + Zod validation
export default router;
Although this is the cleanest approach in my opinion, there is one flaw that makes it a deal breaker for me. We don't have type safety inside the getExercisesRoute on the req. This solution will work perfectly on a project that isn't using TypeScript but I wanted to provide a solution that takes into account type safety too.
Type Safety
To achieve type safety a few things will have to change. First of all our Schema type will have to change slightly:
// ?? It now needs a generic for each property
type Schema<
Query extends StrictZodObject,
Body extends StrictZodObject,
Params extends StrictZodObject,
Headers extends StrictZodObject
> = {
querySchema: Query;
bodySchema: Body;
paramsSchema: Params;
headerSchema: Headers;
};
Why this change is necessary will become clearer a bit later.
Then the validateWithZod function will turn into validateRoute and it will look like so:
type Props<
Query extends StrictZodObject,
Body extends StrictZodObject,
Params extends StrictZodObject,
Headers extends StrictZodObject
> = {
schema: Schema<Query, Body, Params, Headers>;
req: Request;
};
export const validateRoute = <
Query extends StrictZodObject,
Body extends StrictZodObject,
Params extends StrictZodObject,
Headers extends StrictZodObject
>({
schema,
req,
}: Props<Query, Body, Params, Headers>) => {
const { querySchema, bodySchema, paramsSchema, headerSchema } = schema;
const query = parseWithSchema({
data: req.query,
schema: querySchema,
errorMessage: "Unrecognized query parameters",
});
// Remaining parsing
return { query, body, params, headers };
};
This is no longer a middleware function but rather a function that takes the schema and the req, does the parsing and returns the query, body, params and headers with the correct typing, that is going to be called directly in the route. Again you see the four generics and why we need those will become clear when we refactor the parseWithSchema function.
The parseWithSchema function will change to:
type parseWithSchemaType<Schema extends StrictZodObject> = {
data: unknown;
schema: Schema;
errorMessage?: string;
};
// The generic helps us do our magic ??
export const parseWithSchema = <Schema extends StrictZodObject>({
data,
schema,
errorMessage,
}: parseWithSchemaType<Schema>) => {
return schema.parse(data, {
errorMap: (error, ctx) => {
if (error.code === ZodIssueCode.unrecognized_keys && errorMessage) {
return { message: errorMessage };
}
return { message: ctx.defaultError };
},
}) as z.infer<Schema>; // ?? this is where the magic happens ??
};
As you can see, now we're returning the parsed data but we are also inferring the type. This is where the magic happens and this is why we need the 4 generics above. If we don't infer the type at the end of the function we won't get the desired type safety.
Error handling is also moved on a route level:
export const getExercisesRoute = async (req: Request, res: Response) => {
try {
// ... logic goes here
} catch (error) {
// ?? this is what needs adding on a route level
if (error instanceof z.ZodError) {
console.error(error.errors);
return res.status(400).json(error.errors);
}
console.error(error);
res.status(500).json({ message: "Internal server error" });
}
};
And then using our new validateRoute function is as simple as:
export const getAllExercises = async (req: Request, res: Response) => {
try {
const { query, body, headers, params } = validateRoute({
schema: getAllExercisesSchema,
req,
});
// rest of the logic
} catch (error) {
// error logic
};
};
Now query, body, params and headers are all validated and are all correctly typed.
Although this solution requires a bit more upfront work and adding some extra logic on a route level this provides both validation and type safety leveraging Zod to the fullest.
For more on Zod check out one of my other articles - Beyond the Basics: Strategies for Creating Zod Schemas for Data with Interdependent Values
Software Engineer
6 个月nice one!