TypeScript with Fastify: Schema Validation and Type Inference
AI generated
<T>
type
TypeScript · Fastify · JSON Schema · Node.js
TypeScript with Fastify
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.

17 min read JSON Schema · TypeBox · Plugins · Hooks Fastify 4/5 · TypeScript 5.x

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.

11. FAQ: TypeScript with Fastify

1Why JSON Schema instead of separate validation?
Compiled at start, faster, and also serves as the response serializer.
2What does TypeBox add?
Automatically derives the TypeScript type from the schema, they never drift apart.
3How does the response schema filter fields?
Only schema declared fields end up in the JSON response, others are automatically removed.
4Global vs. route specific hook?
Global hook affects the whole encapsulation level, route specific hook only one route.
5What is fastify-plugin for?
Lifts a plugin out of its own encapsulation so decorations are available everywhere.
6How do I extend FastifyInstance type safely?
Via module declaration of the interface combined with decorate() at runtime.
7Are validation errors handled automatically?
Yes, Fastify responds automatically with 400 and a structured error message.
8How do I create custom error classes?
With createError from @fastify/error, caught via a central setErrorHandler.
9Is Fastify faster than Express?
Usually yes, especially with complex response objects thanks to compiled serialization.
10Worth it for existing Express projects?
Full migration rarely worth it, better suited for new services in existing landscapes.