I’ll start this by saying that I’m a big fan of Typebox and use it in most of my projects. However, there are instances where I find myself gravitating towards alternative solutions, such as Zod, which has gained significant traction, particularly within libraries like Slonik. My rationale is straightforward: when I’m consistently utilising schema-driven types for handling both database query results and request validations, it makes practical sense to streamline my workflow by relying on a single, unified library.
Recently, I experimented with fastify-type-provider-zod, a Fastify type provider for Zod. My initial attempts to integrate it were tricky and I wanted to share some of the workarounds I came up with.
Some Background on Type Providers
The primary role of type providers in fastify is to enhance static type checking on things like route parameters and body payloads. This means they help ensure that your code is type-safe during development, that is: before it’s compiled and run. These act as a translation layer to JSON Schema, enabling Fastify to perform runtime validation based on TypeScript types. This functionality is particularly powerful because it bridges the gap between the static type system used by TypeScript at compile time and the runtime validation that is necessary for ensuring the integrity of incoming and outgoing data. Where this caught me out was that you also need to set a ‘validator compiler’ for your given schema type in order for run-time request validation to kick in.
Getting Started
To begin, the standard instantiation of Fastify with the Zod type provider looks like this:
const app = fastify({
logger: true,
}).withTypeProvider<ZodTypeProvider>();
Then set the validator AND serialiser.
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);
Problem 1: Route Plugins
I encountered type issues when defining my route plugins:
export const customers: FastifyPluginAsync<MyPluginOptions> =
async function (fastify) {
fastify.get('/customer/:id', {
schema: {
params: CustomerPathParamsSchema,
response: {
200: CustomerSchema
}
},
}, async function (request, reply) {
const customer = await service.get(request.params.id);
...
})
}
Despite following the standard structure, I was confronted with an unexpected error:
'request.params' is of type 'unknown'.ts(18046)
Interestingly, IntelliSense seemed to provide the expected types, which led to some confusion. To resolve this, I found two approaches. The first involved declaring a global type:
declare global {
type FastifyPluginAsyncZod<
Options extends FastifyPluginOptions = Record<never, never>,
Server extends RawServerBase = RawServerDefault> = FastifyPluginAsync<Options,
Server,
ZodTypeProvider
>;
}
Using FastifyPluginAsyncZod instead of FastifyPluginAsync provided the correct type inferences. Alternatively, I discovered that incorporating the type provider directly within the route plugin also worked:
export const customers: FastifyPluginAsync<MyPluginOptions> =
async function (fastify) {
fastify.withTypeProvider<ZodTypeProvider>()
...
}
This latter method proved to be a practical workaround, ensuring the types were correctly inferred without global declarations.
Problem 2: Decorators
My journey with Fastify and Zod became more complicated when I attempted to introduce a decorator through a plugin. I used an example provided by fastify-jwt:
import jwt from '@fastify/jwt';
export const authenticate = fp(async function(fastify, opts) {
fastify.register(jwt, {
secret: "supersecret"
})
fastify.decorate("authenticate", async function(request, reply) {
try {
await request.jwtVerify()
} catch (err) {
reply.send(err)
}
})
})
However, when integrating this into my route plugin, the ‘unknown’ error emerged once again. It seemed that the request type was somehow mismatched, not aligning with the expected type provider:
export const customers: FastifyPluginAsync<MyPluginOptions> =
async function (fastify) {
fastify.get('/customer/:id', {
schema: {
params: CustomerPathParamsSchema,
response: {
200: CustomerSchema
}
},
onRequest: [
fastify.authenticate
],
}, async function (request, reply) {
const customer = await service.get(request.params.id);
...
})
}
After troubleshooting, I eventually concluded that a more effective approach would be to directly use .addHook within the route plugin, thus bypassing the need for the decorator:
export const customers: FastifyPluginAsync<MyPluginOptions> =
async function (fastify) {
fastify
.withTypeProvider<ZodTypeProvider>()
.addHook('onRequest',
(request, reply) => request.jwtVerify(),
)
.get('/customer/:id', {
...
})
}
Conclusion
Typically, when I develop route plugins, they are part of a larger module representing a specific ‘domain’. My goal is to structure these modules so they can be easily separated and operated independently if necessary. The solutions I’ve found align well with this modular approach.