Fastify Best Practices #4: Use Zod Instead of Ajv for Schema Management
When building server-side applications, validating incoming HTTP request parameters is the first line of defense for ensuring service stability and security. Fastify, a framework known for its performance and low overhead, comes with the powerful Ajv module built-in to handle schema validation efficiently, making it ready to use out of the box.
However, through extensive practice with TypeScript projects, I’ve found that the native Ajv solution isn’t perfect. For those of us who pursue the ultimate developer experience and type safety, it has two significant pain points that are hard to ignore.
The Pain Points of Ajv: Tedious and Disconnected
Let’s look at a typical validation example using the native Ajv:
import { FastifyInstance } from 'fastify';
// Define using the JSON Schema specification
const paramsSchema = {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
},
required: ['id'],
additionalProperties: false,
};
// We also need to manually define the type for req.params
interface IParams {
id: string;
}
const routes = async (fastify: FastifyInstance) => {
fastify.get<{ Params: IParams }>( // Manually associate the type
'/:id',
{
schema: {
params: paramsSchema,
},
},
async (req, reply) => {
const { id } = req.params;
// ...
return reply.status(200).send();
},
);
};
export default routes;
While this approach gets the job done, its shortcomings are obvious:
- Verbose and Tedious Definitions:
Ajv’s JSON Schema syntax is relatively verbose. When validation rules become complex and fields are numerous, the schema object can become exceptionally large and difficult to read and maintain. - Disconnected Type System:
Ajv’s schema definitions and TypeScript’s type system are completely separate. This means you define a schema for runtime validation but still need to manually declare a corresponding TypeScriptinterfaceortypeto get compile-time type hints. This undoubtedly creates duplicate work and introduces the risk of inconsistencies between the two. While tools likejson-schema-to-tscan generate types from schemas, this adds extra dependencies and build steps.
So, is there a more modern, TypeScript-friendly way? The answer is a resounding yes: Zod.
The Solution: Embrace Zod for Unified Types and Validation
Zod is a TypeScript-first validation library that allows you to define a schema once and get both powerful runtime validation and static type inference. With a “glue” dependency called fastify-type-provider-zod, we can make Fastify and Zod work together perfectly.
First, install the dependencies:
npm i fastify-type-provider-zod zod
Then, we can refactor the code like this:
import Fastify from 'fastify';
import { serializerCompiler, validatorCompiler, ZodTypeProvider } from 'fastify-type-provider-zod';
import { z } from 'zod';
const app = Fastify({ logger: true });
// 1. Configure Fastify to use Zod for validation and serialization
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);
// 2. When registering the route, enable the Zod type provider with withTypeProvider<ZodTypeProvider>()
app.withTypeProvider<ZodTypeProvider>().route({
method: 'GET',
url: '/',
// 3. Define the schema directly using Zod's chainable API—it's clear and intuitive
schema: {
querystring: z.object({
name: z.string().min(4).default('Guest'),
}),
response: {
200: z.object({
greeting: z.string(),
}),
},
},
handler: (req, res) => {
// 4. ✨ Time for the magic! ✨
// req.query is automatically inferred as type { name: string }
// No need to manually define any interface or type
const { name } = req.query;
// You can safely use any string methods on name, and IDE autocompletion will work
const greeting = `Hello, ${name.toUpperCase()}!`;
// The response body is also validated and serialized according to the response schema
res.status(200).send({ greeting });
},
});
app.listen({ port: 3000 });
Explaining the Key Changes
Let’s break down the magic in this code:
app.setValidatorCompiler(validatorCompiler);andapp.setSerializerCompiler(serializerCompiler);: These two lines are the core configuration. They tell Fastify: “Stop using your built-in Ajv. Instead, use Zod to validate incoming requests and serialize outgoing responses.”app.withTypeProvider<ZodTypeProvider>(): This magical method injects Zod’s type capabilities into your routes. Because of it, Fastify can understand the Zod objects you write in theschemafield and provide you with perfect type inference. From this point on, thereqobject in yourhandler(includingquery,body, andparams) will have precise types.
The Extra Benefit of Defining a response Schema
You might have noticed that we also defined a response schema in the example above. This isn’t just for clearer documentation or ensuring the output format is correct; it also has a significant performance advantage. Fastify uses this schema to generate an optimized JSON serialization function that is much faster than the standard JSON.stringify. For high-concurrency scenarios, this is a performance boost that should not be overlooked.
Summary
By migrating from Ajv to Zod, we get far more than just cleaner syntax. We gain:
- A Single Source of Truth: One schema definition serves both runtime validation and compile-time type checking. Say goodbye to duplicate definitions and the hassle of types falling out of sync.
- An Excellent Developer Experience (DX): Enjoy the convenience of Zod’s chainable API and the power of type inference, which provides autocompletion and static analysis to help you catch bugs during development.
- Greater Code Robustness: By strictly validating request parameters and response bodies, you significantly improve your application’s stability and reliability.
For any developer building Fastify applications with TypeScript, embracing Zod is undoubtedly a high-return investment.
For a more complete schema example, you can refer to my open-source project, which fully applies the best practices mentioned in this article: