Typed Routes & Schemas
Starting in v1.0.21 the web server exposes first-class support for Zod-powered route schemas. In v1.0.23, we've integrated fastify-type-provider-zod for automatic runtime validation and enhanced type safety.
Instead of pushing ad-hoc validation objects into Fastify at runtime, you can now describe request and response contracts with Zod and receive:
- Fully typed handlers with automatic type inference
- Automatic runtime validation - invalid requests are rejected with 400 errors
- Response serialization
- OpenAPI-ready metadata
This guide walks through the new APIs, how to adopt them in existing controllers, and what migration steps you may need if you were relying on the old validation property.
Why typed routes?
- Type inference end-to-end – the shape of
params,querystring,body, andreplyflows directly into your controller methods. - Automatic validation (v1.0.23+) – Zod schemas are validated by Fastify automatically. Invalid requests return detailed 400 error responses.
- Native Fastify integration – Powered by
fastify-type-provider-zod, with full support for Zod 4. - Docs & client generation – the framework now holds all the metadata needed to emit OpenAPI specs or SDKs later on.
- Safer routing – handlers are registered explicitly, removing the
any-based reflective lookup that previously reached into controller instances.
Quick Example
Here's a complete example showing validation in action:
// src/routes/users.routes.ts
import { defineRoute } from '@scpxl/nodejs-framework/webserver';
import type { RouteSchemaDefinition } from '@scpxl/nodejs-framework/webserver';
import { z } from 'zod';
const createUserSchema = {
body: z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
}),
response: {
201: z.object({
id: z.string(),
email: z.string().email(),
name: z.string(),
}),
},
} satisfies RouteSchemaDefinition;
export const routes = [
defineRoute({
method: 'POST',
path: '/api/users',
schema: createUserSchema,
handler: async (request, reply) => {
// request.body is typed as { email: string; name: string }
// Validation has already passed - no need to check!
const user = await createUser(request.body);
return reply.status(201).send(user);
},
}),
];What happens:
- Valid request: Handler executes with typed
request.body - Invalid email: Returns
400with error message - Missing name: Returns
400with error message - Name too long: Returns
400with error message
All validation is automatic - you just write your business logic!
Defining a Typed Route
You can still describe routes inline on the webserver config, but the new defineRoute helper keeps things succinct and strongly typed.
import { defineRoute } from '@scpxl/nodejs-framework';
import { z } from 'zod';
export default [
defineRoute({
method: 'POST',
path: '/users',
schema: {
body: z.object({
email: z.string().email(),
name: z.string().min(1),
}),
response: {
201: z.object({ id: z.string(), email: z.string().email() }),
},
},
handler: async (request, reply) => {
const user = await createUser(request.body);
return reply.code(201).send(user);
},
}),
];Key points:
schemaaccepts any combination ofparams,querystring,body,headers, andresponse(use status codes as keys).- The
handlerreceives aFastifyRequestwhose types are derived automatically from the Zod schema. - Validation is automatic - Fastify will validate requests against the schema and return 400 errors for invalid data.
- The helper defaults to
WebServerRouteType.Default, but you can passtypeif you need entity routes.
Under the hood (v1.0.23+): The framework uses fastify-type-provider-zod which:
- Passes Zod schemas directly to Fastify (no conversion needed!)
- Configures validators and serializers automatically
- Validates requests before they reach your handler
- Returns detailed validation errors to clients
Using Controllers with Typed Actions
Controllers now expose aliases that make their method signatures clearer:
import { WebServerBaseController, ControllerAction, ControllerRequest } from '@scpxl/nodejs-framework';
import { z } from 'zod';
const createUserSchema = {
body: z.object({
email: z.string().email(),
name: z.string().min(1),
}),
response: {
201: z.object({ id: z.string(), email: z.string().email(), name: z.string() }),
},
} as const;
export type CreateUserSchema = typeof createUserSchema;
export default class UserController extends WebServerBaseController {
public create: ControllerAction<CreateUserSchema> = async (request, reply) => {
const user = await this.services.user.create(request.body);
this.sendSuccessResponse({ reply, data: user, statusCode: 201 });
};
private runAudit(request: ControllerRequest<CreateUserSchema>) {
// Access to fully typed request
void request.body.email;
}
}ControllerAction<Schema>enforces the request/response typing derived fromcreateUserSchema.ControllerRequest<Schema>is available when you need helper methods that share the same typed request.- Inside the controller you still have access to utilities like
sendSuccessResponseand the application resources (database, redis, queues, etc.).
When registering the route you can continue to rely on controller auto-wiring by adding the schema next to the route definition:
import { WebServerRouteType } from '@scpxl/nodejs-framework';
import UserController from './controllers/user.controller.js';
import { createUserSchema } from './schemas/user.js';
export default [
{
type: WebServerRouteType.Default,
method: 'POST',
path: '/users',
controller: UserController,
action: 'create',
schema: createUserSchema,
},
];Prefer a lighter approach? Provide the handler directly with the same schema object:
import { defineRoute } from '@scpxl/nodejs-framework';
import { createUserSchema } from './schemas/user.js';
import { userService } from './services/user.service.js';
export default [
defineRoute({
method: 'POST',
path: '/users',
schema: createUserSchema,
handler: async (request, reply) => {
const user = await userService.create(request.body);
return reply.code(201).send(user);
},
}),
];Swap
userServicefor whichever dependency container or module pattern you prefer—the important part is thatrequest.bodyis fully typed and already validated.
Handler-only Routes
For lightweight endpoints you can now omit controller entirely. Provide handler (and optionally schema) on the route definition and the web server will register it directly.
{
method: 'GET',
path: '/healthz',
handler: async (_req, reply) => reply.send({ ok: true }),
schema: {
response: { 200: z.object({ ok: z.boolean() }) },
},
}Remember to import
zfromzodwhen using inline schemas.
This is especially handy is especially handy for temporary endpoints, internal tooling, or when you want to colocate route logic with feature modules instead of controllers.
Migration from validation
- The old
validationblock (custom schema definitions checked inpreValidation) still works. During registration the framework will translate it to Fastify'sschemafield to avoid breaking existing code. - New code should prefer
schemawith Zod definitions. You get static typing, better editor support, and a future-proof path to generated references. - Replace direct usage of
request.compileValidationSchemawith Zod validation in the schema definition. No manualpreValidationstep is required anymore—Fastify handles it. - When moving controllers over, type methods with
ControllerAction<YourSchema>and adjust references torequest.body,request.params, etc. (the types will now match the schema).
Validation Error Responses
When a request fails validation (v1.0.23+), Fastify automatically returns a 400 Bad Request with detailed error information:
{
"statusCode": 400,
"error": "Bad Request",
"message": "body/email must be a valid email"
}The error messages come directly from Zod and include:
- The path to the invalid field (e.g.,
body/email,params/id) - A description of what went wrong
- Multiple errors if multiple fields are invalid
You don't need to write validation code in your handlers - it's all automatic!
Using defineAction() Helper
For controller methods, you can use the defineAction() helper to get full type inference:
import { defineAction } from '@scpxl/nodejs-framework/webserver';
import { z } from 'zod';
const helloSchema = {
body: z.object({ name: z.string() }),
response: { 200: z.object({ message: z.string() }) },
} satisfies RouteSchemaDefinition;
class MyController extends WebServerBaseController {
// With schema - fully typed
hello = defineAction(helloSchema, async (request, reply) => {
// request.body.name is typed as string
return reply.send({ message: `Hello ${request.body.name}` });
});
// Without schema - still works
ping = defineAction(async (_request, reply) => {
return reply.send({ status: 'pong' });
});
}The defineAction() helper is purely for type inference - it returns the handler function unchanged. The actual validation happens at the route registration level when you provide a schema.
What about entity routes?
Entity controllers (WebServerRouteType.Entity) can also attach schema. The default CRUD definitions will inherit it for create/update variants. If you need per-action variations you can use handler overrides or define multiple routes with distinct schemas.
Next up: OpenAPI
Because every route now carries canonical schemas, the framework can generate OpenAPI specs without additional annotations. A future release will add tooling to emit /docs/json dynamically—no changes will be required on your part beyond using the typed routes described above.
Questions or feedback? Open an issue or reach out in the community Discord.