Chapter 20

Node.js Backend: Fastify + TypeBox Route-Level Type Inference

The Express Type Problem

Express is the most popular Node.js framework, but its type design has a fundamental flaw: the request body is any.

// Express route โ€” the type system is decorative
app.post('/users', (req, res) => {
  const name = req.body.name;   // type: any
  const age = req.body.age;     // type: any
  const typo = req.body.naem;   // type: any โ€” typo, compiler says nothing
  res.json({ id: '123' });
});

req.body defaults to any, meaning you can access any non-existent property and the compiler stays silent. Even adding generics manually is cosmetic:

// Express generics: manual declaration, easy to drift
interface CreateUserBody {
  name: string;
  age: number;
}

app.post<{}, {}, CreateUserBody>('/users', (req, res) => {
  const name = req.body.name;  // now string โ€” but this is your assertion, not inference
  // Express does NOT validate that runtime data actually matches this type
  // If the client sends { name: 123 }, TypeScript won't catch it
});

The root problem: types and validation logic are separate. Types exist at compile time; validation happens at runtime. They operate independently โ€” a bug in either one does not affect the other.

The correct Express approach involves zod or joi for runtime validation, then type assertions to inform the compiler. This works but requires boilerplate, and the two systems (schema + type) must be kept in sync manually.

Fastify + TypeBox: Schema Is the Type

Fastify treats JSON Schema as a first-class citizen from the beginning. TypeBox is a library that lets you write objects that simultaneously satisfy JSON Schema spec and carry TypeScript type information โ€” one definition, two uses.

Installation

npm install fastify @fastify/type-provider-typebox @sinclair/typebox

TypeBox basics: building schemas

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

// Primitive types
const NameSchema = Type.String({ minLength: 1, maxLength: 100 });
const AgeSchema = Type.Number({ minimum: 0, maximum: 150 });

// Object schema
const CreateUserBodySchema = Type.Object({
  name: Type.String({ minLength: 1 }),
  age: Type.Number({ minimum: 0 }),
  email: Type.Optional(Type.String({ format: 'email' })),
});

// Extract the TypeScript type from the schema
type CreateUserBody = Static<typeof CreateUserBodySchema>;
// Equivalent to: { name: string; age: number; email?: string }

// The JSON Schema that TypeBox generates โ€” Fastify uses this for runtime validation
console.log(JSON.stringify(CreateUserBodySchema));
// {"type":"object","properties":{"name":{"type":"string","minLength":1},...},"required":["name","age"]}

Type.Object() returns an object that is both a valid JSON Schema (passable to any JSON Schema validator) and carries TypeScript type information (extracted via Static<>).

TypeBox building blocks

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

// Primitives
Type.String()                    // string
Type.Number()                    // number
Type.Boolean()                   // boolean
Type.Null()                      // null
Type.Literal('admin')            // 'admin'

// Modifiers
Type.Optional(Type.String())     // string | undefined (in object properties)
Type.Readonly(Type.String())     // readonly string

// Composite types
Type.Array(Type.String())        // string[]
Type.Tuple([Type.String(), Type.Number()])  // [string, number]
Type.Union([Type.String(), Type.Number()])  // string | number
Type.Intersect([SchemaA, SchemaB])          // A & B

// Objects
Type.Object({ id: Type.String(), count: Type.Number() })

// Enums
Type.Enum({ Active: 'active', Inactive: 'inactive' })

// Constrained strings
Type.String({ minLength: 1, maxLength: 255, pattern: '^[a-z]+$' })

// Ranged numbers
Type.Number({ minimum: 0, maximum: 100, multipleOf: 1 })

Route-Level Full Type Inference

import Fastify from 'fastify';
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
import { Type } from '@sinclair/typebox';

const fastify = Fastify().withTypeProvider<TypeBoxTypeProvider>();

// Define the route schema
const CreateUserSchema = {
  body: Type.Object({
    name: Type.String({ minLength: 1 }),
    age: Type.Number({ minimum: 0 }),
    role: Type.Optional(Type.Union([
      Type.Literal('admin'),
      Type.Literal('user'),
    ])),
  }),
  response: {
    201: Type.Object({
      id: Type.String(),
      name: Type.String(),
      createdAt: Type.String(),
    }),
    400: Type.Object({
      error: Type.String(),
      message: Type.String(),
    }),
  },
} as const;

fastify.post('/users', { schema: CreateUserSchema }, async (req, reply) => {
  // req.body type is fully inferred:
  // { name: string; age: number; role?: 'admin' | 'user' }
  const { name, age, role } = req.body;

  // name is string โ€” the compiler knows
  const upperName = name.toUpperCase();

  // role is 'admin' | 'user' | undefined โ€” union narrowing works correctly
  if (role === 'admin') {
    // role narrowed to 'admin' here
  }

  // reply.code(201).send() argument type matches the response[201] schema
  return reply.code(201).send({
    id: crypto.randomUUID(),
    name,
    createdAt: new Date().toISOString(),
  });
});

The key: .withTypeProvider<TypeBoxTypeProvider>() tells Fastify which type provider to use. After that, each route's schema field automatically infers the types for req.body, req.params, and req.query.

Route params and query strings

const GetUserSchema = {
  params: Type.Object({
    id: Type.String({ pattern: '^[0-9a-f-]{36}$' }),
  }),
  querystring: Type.Object({
    include: Type.Optional(Type.Array(Type.String())),
    format: Type.Optional(Type.Union([
      Type.Literal('json'),
      Type.Literal('csv'),
    ])),
  }),
  response: {
    200: Type.Object({
      id: Type.String(),
      name: Type.String(),
      age: Type.Number(),
    }),
    404: Type.Object({ error: Type.String() }),
  },
};

fastify.get('/users/:id', { schema: GetUserSchema }, async (req, reply) => {
  const { id } = req.params;              // string
  const { include, format } = req.query;  // string[] | undefined, 'json' | 'csv' | undefined

  const user = await db.users.findById(id);
  if (!user) {
    return reply.code(404).send({ error: 'User not found' });
  }
  return reply.send(user);
});

Typed Middleware: Augmenting Request/Reply

Fastify's plugin system lets you attach data to the request object. Via declaration merging, types stay synchronized.

// auth-plugin.ts
import { FastifyPluginAsync, FastifyRequest } from 'fastify';
import fp from 'fastify-plugin';

// Augment Fastify's type declarations
declare module 'fastify' {
  interface FastifyRequest {
    user: {
      id: string;
      role: 'admin' | 'user';
      email: string;
    } | null;
  }
}

const authPlugin: FastifyPluginAsync = async (fastify) => {
  fastify.addHook('preHandler', async (request, reply) => {
    const token = request.headers.authorization?.replace('Bearer ', '');
    if (!token) {
      request.user = null;
      return;
    }
    try {
      request.user = await verifyToken(token);
    } catch {
      reply.code(401).send({ error: 'Invalid token' });
    }
  });
};

export default fp(authPlugin);
// Using it in a route
fastify.get('/profile', {
  preHandler: [requireAuth],
  schema: { response: { 200: UserProfileSchema } }
}, async (req, reply) => {
  // req.user type: { id: string; role: 'admin' | 'user'; email: string } | null
  if (!req.user) {
    return reply.code(401).send({ error: 'Unauthorized' });
  }
  // req.user narrowed to non-null here
  const profile = await db.profiles.findByUserId(req.user.id);
  return reply.send(profile);
});

Typed Error Handling

Fastify's error handler hook is also fully typeable:

import { FastifyError } from 'fastify';

class AppError extends Error {
  constructor(
    public readonly statusCode: number,
    public readonly code: string,
    message: string,
  ) {
    super(message);
    this.name = 'AppError';
  }
}

fastify.setErrorHandler((error: FastifyError | AppError, request, reply) => {
  if (error instanceof AppError) {
    return reply.code(error.statusCode).send({
      code: error.code,
      message: error.message,
    });
  }

  // Fastify's validation errors
  if (error.validation) {
    return reply.code(400).send({
      code: 'VALIDATION_ERROR',
      message: 'Request validation failed',
      details: error.validation,
    });
  }

  fastify.log.error(error);
  return reply.code(500).send({
    code: 'INTERNAL_ERROR',
    message: 'An unexpected error occurred',
  });
});

Fastify automatically throws a structured error when schema validation fails โ€” you do not need to manually check types in each route handler.

Prisma Integration: Type-Safe Database Queries

Prisma auto-generates types for every model, and it pairs naturally with Fastify + TypeBox.

// schema.prisma
model User {
  id        String   @id @default(uuid())
  name      String
  age       Int
  email     String?  @unique
  role      Role     @default(USER)
  posts     Post[]
  createdAt DateTime @default(now())
}

enum Role {
  ADMIN
  USER
}
import { PrismaClient } from '@prisma/client';
import { Type, Static } from '@sinclair/typebox';

const prisma = new PrismaClient();

const CreateUserBodySchema = Type.Object({
  name: Type.String({ minLength: 1 }),
  age: Type.Number({ minimum: 0, maximum: 150 }),
  email: Type.Optional(Type.String({ format: 'email' })),
});

fastify.post('/users', {
  schema: { body: CreateUserBodySchema, response: { 201: UserResponseSchema } }
}, async (req, reply) => {
  const { name, age, email } = req.body;

  // prisma.user.create argument types are inferred by Prisma
  // Passing a field that doesn't exist in the schema causes a compile error
  const user = await prisma.user.create({
    data: { name, age, email },
    select: {
      id: true,
      name: true,
      createdAt: true,
    },
  });

  // user type: { id: string; name: string; createdAt: Date }
  // select changes the return type โ€” TypeScript infers it automatically
  return reply.code(201).send({
    ...user,
    createdAt: user.createdAt.toISOString(),
  });
});

How select and include affect the return type

// Different queries, different types
const userBasic = await prisma.user.findUnique({
  where: { id },
  select: { id: true, name: true },
});
// type: { id: string; name: string } | null

const userWithPosts = await prisma.user.findUnique({
  where: { id },
  include: { posts: true },
});
// type: (User & { posts: Post[] }) | null

// Use Prisma.UserGetPayload to extract the type for complex queries
import { Prisma } from '@prisma/client';

type UserWithPosts = Prisma.UserGetPayload<{
  include: { posts: true };
}>;
// equivalent to User & { posts: Post[] }

Complete CRUD Example

import Fastify from 'fastify';
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
import { Type } from '@sinclair/typebox';
import { PrismaClient } from '@prisma/client';

const fastify = Fastify({ logger: true }).withTypeProvider<TypeBoxTypeProvider>();
const prisma = new PrismaClient();

const UserIdParam = Type.Object({ id: Type.String() });

const UserBody = Type.Object({
  name: Type.String({ minLength: 1 }),
  age: Type.Number({ minimum: 0 }),
  email: Type.Optional(Type.String({ format: 'email' })),
});

const UserResponse = Type.Object({
  id: Type.String(),
  name: Type.String(),
  age: Type.Number(),
  email: Type.Union([Type.String(), Type.Null()]),
  createdAt: Type.String(),
});

// GET /users
fastify.get('/users', {
  schema: { response: { 200: Type.Array(UserResponse) } }
}, async (req, reply) => {
  const users = await prisma.user.findMany();
  return users.map(u => ({ ...u, createdAt: u.createdAt.toISOString() }));
});

// POST /users
fastify.post('/users', {
  schema: { body: UserBody, response: { 201: UserResponse } }
}, async (req, reply) => {
  const user = await prisma.user.create({ data: req.body });
  return reply.code(201).send({ ...user, createdAt: user.createdAt.toISOString() });
});

// GET /users/:id
fastify.get('/users/:id', {
  schema: {
    params: UserIdParam,
    response: { 200: UserResponse, 404: Type.Object({ error: Type.String() }) }
  }
}, async (req, reply) => {
  const user = await prisma.user.findUnique({ where: { id: req.params.id } });
  if (!user) return reply.code(404).send({ error: 'Not found' });
  return { ...user, createdAt: user.createdAt.toISOString() };
});

// PATCH /users/:id
fastify.patch('/users/:id', {
  schema: {
    params: UserIdParam,
    body: Type.Partial(UserBody),   // all fields become optional
    response: { 200: UserResponse },
  }
}, async (req, reply) => {
  const user = await prisma.user.update({
    where: { id: req.params.id },
    data: req.body,
  });
  return { ...user, createdAt: user.createdAt.toISOString() };
});

// DELETE /users/:id
fastify.delete('/users/:id', {
  schema: { params: UserIdParam, response: { 204: Type.Null() } }
}, async (req, reply) => {
  await prisma.user.delete({ where: { id: req.params.id } });
  return reply.code(204).send();
});

Anti-Patterns

Anti-pattern Problem Correct approach
(req.body as CreateUserBody) Bypasses validation; the type is a lie Use TypeBox Schema + TypeBoxTypeProvider
Manual validation outside the route, then assert Validation logic duplicated, drifts from schema Let Fastify + TypeBox handle runtime validation
Type.Any() or Type.Unknown() for the whole body Abandons type safety Define each field specifically
Forgetting .withTypeProvider<TypeBoxTypeProvider>() Type inference breaks; body stays unknown Add it immediately when initializing Fastify
Returning Prisma Date objects directly Date does not serialize correctly with JSON.stringify Convert to .toISOString() strings

Summary

Capability Express Fastify + TypeBox
req.body type any (default) Auto-inferred from schema
Runtime validation Manual (requires zod/joi) Built-in (JSON Schema)
Schema/type sync Two systems to maintain manually One definition, automatically in sync
Response type checking None reply.send() argument is typed
Params / query strings any Inferred from schema
Performance Baseline 2โ€“10ร— faster than Express (Fastify's design goal)

Next chapter moves to the frontend: how to type React components correctly โ€” why React.FC is discouraged, how to write generic components, and the type inference details behind hooks.

Rate this chapter
4.8  / 5  (9 ratings)

๐Ÿ’ฌ Comments