Schema validation and automatic type inference
Fastify does not treat JSON Schema as an add on feature but as a core building block: the same schema validates incoming requests at runtime, speeds up response serialization and, combined with TypeBox, automatically delivers the matching TypeScript type, without developers maintaining types twice by hand.
Table of contents
- 1. Why Fastify differs from classic Node frameworks
- 2. JSON Schema as a single source of truth
- 3. Automatic type inference with TypeBox
- 4. Registering plugins and encapsulation type safely
- 5. Type safe hooks: onRequest and preHandler
- 6. Error handling with typed Fastify errors
- 7. Performance: serialization and schema compilation
- 8. Extending decorators type safely
- 9. Fastify compared to Express and NestJS
- 10. Summary
- 11. FAQ
1. Why Fastify differs from classic Node frameworks
Fastify was designed from the start with two goals: high throughput and structured validation through JSON Schema. While Express leaves validation entirely to the developer, it is a built in part of every route definition in Fastify. Anyone combining TypeScript with Fastify gets an advantage no other common Node framework offers in this form: a single schema simultaneously delivers runtime validation, faster JSON serialization and, with the right library, the matching static type.
This combination solves a problem that often causes inconsistencies in other frameworks: type and runtime check drift apart because they are maintained separately. With Fastify there is only one source of truth, the schema itself, from which everything else is derived. This article shows how TypeScript and Fastify work together consistently, from route definitions to plugins and hooks.
Moving from Express to Fastify is especially worthwhile for new projects that plan for structured validation and high request volumes from the start. Existing Express applications are rarely migrated wholesale, teams typically evaluate Fastify for new services within an existing microservice landscape instead.
2. JSON Schema as a single source of truth
Every route in Fastify can define a schema object with the keys body, querystring, params, headers and response. The schema is compiled once at server start into a highly efficient validation function, which is significantly faster than validation that has to be reinterpreted on every single request. For TypeScript with Fastify it is essential that the response schema automatically strips out any field not listed in the schema, which prevents accidental data leaking, for example when an internal password hash field never appears in the schema and therefore never ends up in the response.
Writing plain JSON Schema as a plain object works, but it does not provide automatic TypeScript type inference. That is exactly where the next section comes in: with TypeBox, JSON Schema compatible objects can be declared directly in TypeScript, so schema and type no longer need to be maintained separately.
// plain JSON Schema route — validates but has no automatic TS type
fastify.get("/health", {
schema: {
response: {
200: {
type: "object",
properties: {
status: { type: "string" },
uptime: { type: "number" },
},
required: ["status", "uptime"],
},
},
},
}, async (request, reply) => {
return { status: "ok", uptime: process.uptime() };
});
3. Automatic type inference with TypeBox
TypeBox generates JSON Schema conformant objects directly from TypeScript function calls and simultaneously derives a static type using Static<typeof Schema>. Combined with the @fastify/type-provider-typebox plugin, the Fastify instance type is extended so that request.body, request.params and request.query automatically get the correct type derived from the schema, without a single additional type annotation in the handler.
The result: when the schema changes, the type in the handler changes automatically too, and the compiler immediately flags it when a field was renamed or removed but still used elsewhere. This tight coupling between runtime schema and static type is the central difference between Fastify and frameworks where validation and typing are two separate steps that must be kept in sync manually.
// typebox-routes.ts — schema and type from a single source
import { Type, Static } from "@sinclair/typebox";
import { TypeBoxTypeProvider } from "@fastify/type-provider-typebox";
import Fastify from "fastify";
const CreateItemBody = Type.Object({
name: Type.String({ minLength: 2, maxLength: 120 }),
price: Type.Number({ minimum: 0 }),
tags: Type.Optional(Type.Array(Type.String())),
});
// Static type is derived automatically, never written by hand
type CreateItemBodyType = Static<typeof CreateItemBody>;
const ItemResponse = Type.Object({
id: Type.String(),
name: Type.String(),
price: Type.Number(),
});
const app = Fastify().withTypeProvider<TypeBoxTypeProvider>();
app.post("/items", {
schema: {
body: CreateItemBody,
response: { 201: ItemResponse },
},
}, async (request, reply) => {
// request.body is fully typed as CreateItemBodyType — no manual cast
const { name, price } = request.body;
reply.code(201);
return { id: "item_1", name, price };
});
4. Registering plugins and encapsulation type safely
Fastify structures applications through a plugin system with encapsulation context: each plugin receives its own instance of the Fastify application, and decorations and hooks of a plugin are not visible outside by default, unless explicitly registered as global. For TypeScript with Fastify it matters to mark plugins with fastify-plugin when their decorations should actually be available beyond the encapsulation boundary, otherwise the compiler reports a missing decorator elsewhere.
A plugin is typed via FastifyPluginAsync with an optional generic parameter for plugin options. These options are type checked upon registration, so a mistyped or missing configuration key surfaces at compile time instead of producing an unclear runtime error.
// db-plugin.ts — typed Fastify plugin with typed options
import fp from "fastify-plugin";
import type { FastifyPluginAsync } from "fastify";
interface DbPluginOptions {
connectionString: string;
poolSize?: number;
}
declare module "fastify" {
interface FastifyInstance {
db: { query: (sql: string) => Promise<unknown> };
}
}
const dbPlugin: FastifyPluginAsync<DbPluginOptions> = async (
fastify,
opts
) => {
const pool = createPool(opts.connectionString, opts.poolSize ?? 10);
fastify.decorate("db", {
query: (sql: string) => pool.query(sql),
});
fastify.addHook("onClose", async () => {
await pool.end();
});
};
// fp() lifts the plugin out of its own encapsulation context
export default fp(dbPlugin, { name: "db-plugin" });
5. Type safe hooks: onRequest and preHandler
Fastify defines a fixed order of lifecycle hooks: onRequest, preParsing, preValidation, preHandler and further ones after the handler. Each hook type gets its own signature in TypeScript that differs slightly from generic middleware as known from Express. A preHandler hook is suited for tasks that should run after validation but before the actual route handler, for example loading additional data based on already validated request fields.
A common mistake when starting out with Fastify: a hook is registered globally with fastify.addHook even though it should only apply to a specific route. Because global hooks affect all routes at the current encapsulation level, an overly broad hook can create unexpected side effects in completely unrelated routes. The correct alternative is a route specific hook directly in the individual route's schema definition.
6. Error handling with typed Fastify errors
Fastify automatically returns a structured error response with status code 400 on schema validation failures, without the developer writing any code for it. For custom error classes, @fastify/error provides a factory function that creates typed error classes with a fixed status code mapping. These errors can be caught centrally via setErrorHandler, whose type signature accepts the error, request and reply in a type safe way.
For TypeScript with Fastify it is important to design the error handler to distinguish between schema validation errors, custom Fastify errors and unexpected runtime errors. A central instanceof check per error class prevents internal stack traces from accidentally being returned to the client, while expected errors still produce clear, structured responses.
// errors.ts — typed error factory with @fastify/error
import createError from "@fastify/error";
export const ItemNotFoundError = createError<[]>(
"ITEM_NOT_FOUND",
"Item not found",
404
);
export const RateLimitedError = createError<[number]>(
"RATE_LIMITED",
"Too many requests, retry after %d seconds",
429
);
// route.ts
app.get("/items/:id", async (request, reply) => {
const item = await itemService.findById(request.params.id);
if (!item) {
throw new ItemNotFoundError();
}
return item;
});
7. Performance: serialization and schema compilation
The reason for Fastify's high throughput is not only architectural, it lies specifically in serialization: the response schema is compiled into a specialized serialization function that is significantly faster than the generic JSON.stringify. This function does not need to guess which fields exist in the object, it already knows the structure from the schema and can assemble the JSON string directly in the correct order.
For TypeScript projects this means: a carefully maintained response schema is not just a security feature but also a performance feature. Without the schema, Fastify falls back to the slower JSON.stringify, without this being immediately visible in the code. A regular check whether all production routes define a response schema therefore belongs in every code review checklist for Fastify projects.
8. Extending decorators type safely
Fastify allows decorating the Fastify instance, the request or the reply with custom properties, for example a database client or an authenticated user. For TypeScript to know about these decorations, the respective interface must be extended via module declaration, as shown in the plugin example above. Without this extension, the compiler reports an error on every access to the decoration, even if decorate() was correctly called at runtime.
An important difference from simple global variables: Fastify checks at runtime whether a decoration name is already taken and throws an error at server start in that case. This combination of runtime checking and static typing significantly reduces the risk of conflicting decorations, especially in larger codebases with multiple independently developed plugins.
| Criterion | Fastify | Express | NestJS |
|---|---|---|---|
| Validation built in | Yes, via JSON Schema | No, only via middleware | Yes, via pipes/class-validator |
| Serialization performance | Very high, compiled schema | Standard JSON.stringify | Standard JSON.stringify |
| Architecture guidance | Plugins, flexible | None, freely chosen | Modules, decorators, DI required |
| Learning curve | Medium (schema concept) | Low | High (decorators, modules) |
9. Fastify compared to Express and NestJS
Comparing directly shows that Fastify sits right between the flexibility of Express and the strong architectural guidance of NestJS. Anyone wanting maximum control over the application structure while still needing built in schema validation and high performance finds a good middle ground in Fastify. NestJS enforces a specific architecture through modules and decorators, which brings consistency in large teams but comes with a steeper learning curve.
For TypeScript teams already experienced with JSON Schema or OpenAPI, moving to Fastify is usually straightforward because existing schemas can be reused directly. Teams without this experience should budget for the additional learning effort around TypeBox or similar libraries before settling on Fastify as their standard framework.
Mironsoft
TypeScript backends, API architecture and performance optimization
Fastify backend with a clean schema structure?
We build Fastify services with TypeBox schemas, type safe plugins and optimized response schemas for maximum throughput and minimal error rates in production.
Schema design
TypeBox schemas for body, response and query from a single source
Plugin architecture
Clean encapsulation and typed decorators for growing teams
Performance audit
Reviewing response schemas and removing serialization bottlenecks
10. Summary
TypeScript with Fastify differs fundamentally from other Node frameworks because JSON Schema is not bolted on afterwards, it is treated as a core building block from the start. A schema validates incoming data, speeds up response serialization and, combined with TypeBox, automatically delivers the matching static type. Plugins encapsulate functionality through their own encapsulation context, while decorators and hooks are extended type safely through module declarations.
The biggest advantage shows in consistency: schema and type can never drift apart because both come from the same source. For teams already working with OpenAPI or JSON Schema, moving to Fastify is a natural fit. For teams without this prior experience, the initial learning curve pays off because it produces fewer inconsistencies between validation and typing long term than a manually maintained stack of Express plus separate validation libraries.
TypeScript with Fastify: The essentials at a glance
JSON Schema as the source
One schema for body, query, params and response delivers validation and serialization at once.
TypeBox type inference
Static<typeof Schema> derives the TypeScript type automatically from the schema, no duplication.
Plugins & encapsulation
fastify-plugin for shared decorations, otherwise hooks and decorators stay locally encapsulated.
Performance
Compiled response schema clearly beats generic JSON.stringify in throughput tests.