Alright, let’s talk about the part of Fastify that makes TypeScript developers do a little happy dance: schema-based request typing. This is where Fastify flexes hard on other frameworks. While Express has you writing any or awkward req as RequestWithBody casts, Fastify bakes type safety directly into your route’s DNA. It’s not a bolted-on afterthought; it’s the foundation.

The magic lies in Fastify’s use of JSON Schema. You define the shape and validation rules for your request’s body, querystring, params, and even headers using a schema. Fastify then does two brilliant things for you: it validates incoming requests against that schema (bye-bye, a lot of your manual validation logic!) and, crucially, it generates strong TypeScript types from that schema. You write the rules, and Fastify hands you the types. It’s like getting the blueprint and the finished product at the same time.

Defining Your Schemas with TypeBox (The Sane Choice)

You can write raw JSON Schema objects. I don’t recommend it. It’s verbose and you lose type checking on the schema itself. Instead, let’s use @sinclair/typebox. It’s a library that lets you build JSON Schemas with a TypeScript-like syntax and, crucially, it also generates static TypeScript types. It’s a perfect symbiosis with Fastify.

First, install it: npm install @sinclair/typebox

import { Type, Static } from '@sinclair/typebox';

// Define a User schema
const UserSchema = Type.Object({
  id: Type.Number(),
  username: Type.String({ minLength: 3 }),
  email: Type.String({ format: 'email' }), // Fastify will validate this format too!
  hobbies: Type.Array(Type.String(), { default: [] }) // A default value? Oh yes.
});

// This is the magic: derive a TypeScript type from the schema
type User = Static<typeof UserSchema>;

// Now, let's use it in a route
server.post('/user', {
  schema: {
    body: UserSchema, // Plug the schema in here
    response: {
      201: UserSchema // You can even type the responses! More on this later.
    }
  }
}, async (request, reply) => {

  // `request.body` is now fully typed as `User`
  const newUser: User = request.body; // No cast needed!

  // Try to access a non-existent property? Enjoy your red squiggly line.
  // console.log(request.body.password); // Property 'password' does not exist on type...

  // Your business logic here. The data is already validated and typed.
  const createdUser = await userService.create(newUser);

  // Reply with the typed response
  reply.code(201).send(createdUser);
});

See what happened? The type of request.body is inferred automatically based on the body schema you provided. It’s User. This is lightyears ahead of the Express + body-parser world.

The Pitfall: Beware of Async Schemas and Compilation

Here’s the first “questionable choice” you’ll run into. Fastify compiles your schemas on the fly for performance. This is great, but it’s a synchronous operation. If you try to define a schema asynchronously (e.g., fetching a value from a database to use as a default), you’re going to have a bad time.

The solution is simple but imperative: define all your schemas upfront, before you call server.listen(). Don’t define routes or their schemas inside async hooks or after the server has started. Do it all at startup, synchronously. If you need a dynamic value, like a default from a config, load that config before you define the schema.

Level Up: Composing Schemas for DRYness

You’re not just building endpoints; you’re building an API. Consistency is key. TypeBox and JSON Schema let you compose and reuse schemas beautifully.

// base.schemas.ts
import { Type } from '@sinclair/typebox';

// A common "id" parameter schema used in many routes
export const ParamsWithId = Type.Object({
  id: Type.Integer({ minimum: 1 }) // Ensuring IDs are positive integers
});
export type ParamsWithId = Static<typeof ParamsWithId>;

// user.schemas.ts
import { Type } from '@sinclair/typebox';
import { ParamsWithId } from './base.schemas.js';

// A schema for creating a user (note: no id required)
const CreateUserSchema = Type.Omit(UserSchema, ['id']);

// Now a route that uses both a param and a body schema
server.put('/user/:id', {
  schema: {
    params: ParamsWithId, // Validate that `:id` is a positive integer
    body: CreateUserSchema // Validate the update body
  }
}, async (request, reply) => {
  // request.params is typed as { id: number }
  // request.body is typed as Omit<User, "id">
  const updatedUser = await userService.update(request.params.id, request.body);
  reply.send(updatedUser);
});

This is where you start to feel like a architect instead of a plumber. You’re building a coherent, well-typed system, not just patching together individual routes.

The Edge Case: When Your Schema Can’t Be Static

Sometimes, you genuinely need dynamic validation. Maybe a field’s validity depends on the value of another field. For these truly gnarly scenarios, Fastify’s schema validation might feel like a straitjacket. The escape hatch is to use hooks.

You can use a preValidation hook to run your own custom validation logic after the schema validation has passed. Your schema can handle the basic shape ({ email: string, signupCode: string }) and your hook can handle the complex logic (is this signupCode valid for this email domain?). This keeps the schema for what it’s best at—structural validation—and gives you a clean, typed place to handle the truly weird stuff. The best part? The request.body in your hook is already typed, so you’re working with validated data from the start.