← Back to DevBytes

Fastify TypeScript: Strongly Typed Applications

Building Strongly Typed Applications with Fastify and TypeScript

Fastify is a high-performance, low-overhead web framework for Node.js that focuses on speed and developer experience. When paired with TypeScript, you unlock end-to-end type safety across your entire application — from route handlers to plugins, from request validation to response serialization. This tutorial walks you through creating a strongly typed Fastify application using TypeScript, covering setup, core concepts, and best practices.

What Makes Fastify TypeScript So Powerful?

Fastify's design naturally complements TypeScript. The framework uses a schema-based approach for input validation and output serialization. These schemas define the shape of your data at runtime, but with the right TypeScript integration, they also become the source of compile-time types. This eliminates manual type definitions, reduces duplication, and guarantees that your code respects the contracts you've defined.

Key benefits include:

Setting Up a Fastify TypeScript Project

Start by initialising a new Node.js project and installing the necessary dependencies.

mkdir fastify-ts-app && cd fastify-ts-app
npm init -y
npm install fastify @fastify/type-provider-typebox @sinclair/typebox
npm install --save-dev typescript @types/node tsx

Create a tsconfig.json that enables strict type checking and modern module resolution:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "node",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "dist",
    "rootDir": "src",
    "declaration": true
  },
  "include": ["src/**/*.ts"]
}

For a quick start, you can run TypeScript files directly using tsx without a build step. Add a script to package.json:

"scripts": {
  "dev": "tsx watch src/server.ts"
}

Your First Typed Fastify Server

Create src/server.ts and set up a minimal server. Notice how we use the TypeBoxTypeProvider to bridge TypeBox schemas and Fastify’s type system.

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

// Create a Fastify instance with the TypeBox type provider
const server = Fastify({
  logger: true,
}).withTypeProvider<TypeBoxTypeProvider>();

// Define a typed GET route
server.get('/health', {
  schema: {
    response: {
      200: Type.Object({
        status: Type.String(),
        uptime: Type.Number(),
      }),
    },
  },
  async (request, reply) => {
    return {
      status: 'ok',
      uptime: process.uptime(),
    };
  },
});

// Start listening
server.listen({ port: 3000 }, (err, address) => {
  if (err) {
    server.log.error(err);
    process.exit(1);
  }
  console.log(`Server listening at ${address}`);
});

The response schema Type.Object(...) does double duty: Fastify uses it at runtime to serialise and validate the response, while TypeScript infers the exact return type of the handler. You get type errors immediately if the handler returns something that doesn’t match the schema.

Typing Request Inputs

One of the biggest advantages comes from typing request inputs — query strings, URL parameters, headers, and bodies. Define a schema for each and enjoy fully typed request properties.

// Example: typed query string and params
server.get('/users/:userId', {
  schema: {
    params: Type.Object({
      userId: Type.String({ pattern: '^[0-9]+$' }),
    }),
    querystring: Type.Object({
      includeProfile: Type.Boolean({ default: false }),
      format: Type.Union([Type.Literal('json'), Type.Literal('xml')]),
    }),
    response: {
      200: Type.Object({
        userId: Type.String(),
        name: Type.String(),
        profile: Type.Optional(Type.Object({
          bio: Type.String(),
        })),
      }),
    },
  },
  async (request, reply) => {
    // request.params is typed as { userId: string }
    const { userId } = request.params;

    // request.query is typed as { includeProfile: boolean; format: 'json' | 'xml' }
    const { includeProfile, format } = request.query;

    // Build response — TypeScript enforces the schema
    const user = await fetchUser(userId, includeProfile);
    reply.send(user);
  },
});

Notice how request.params.userId is a string (not unknown) and request.query.includeProfile is a boolean. No manual type assertions needed. Fastify’s validation layer also rejects invalid input at runtime, giving you a true double layer of safety.

Typed Request Bodies

POST and PUT routes benefit from the same approach. Define a body schema and the handler’s request.body will be typed accordingly.

server.post('/users', {
  schema: {
    body: Type.Object({
      name: Type.String({ minLength: 2, maxLength: 100 }),
      email: Type.String({ format: 'email' }),
      role: Type.Union([
        Type.Literal('admin'),
        Type.Literal('user'),
      ]),
    }),
    response: {
      201: Type.Object({
        id: Type.String(),
        name: Type.String(),
        email: Type.String(),
        role: Type.String(),
      }),
    },
  },
  async (request, reply) => {
    // request.body is typed as { name: string; email: string; role: 'admin' | 'user' }
    const { name, email, role } = request.body;

    const newUser = await createUser({ name, email, role });
    reply.code(201).send(newUser);
  },
});

TypeBox schemas support a wide range of validators and transforms, and they integrate seamlessly with Fastify’s serialisation and validation pipeline. Because the type provider infers the static types from the schemas, you never need to duplicate interfaces.

Working with Plugins and Decorators

Fastify’s plugin system allows you to encapsulate functionality and share it across routes. TypeScript can track these custom properties through module augmentation.

Suppose you want to attach a database client to the Fastify instance. First, declare the new property using declare module:

import { PrismaClient } from '@prisma/client';

declare module 'fastify' {
  interface FastifyInstance {
    db: PrismaClient;
  }
}

Then, in a plugin, decorate the instance:

import fp from 'fastify-plugin';

async function databasePlugin(instance: FastifyInstance) {
  const prisma = new PrismaClient();
  await prisma.$connect();
  instance.decorate('db', prisma);

  instance.addHook('onClose', async () => {
    await prisma.$disconnect();
  });
}

export default fp(databasePlugin);

After registering the plugin, every route handler can access request.server.db with full type inference:

server.register(databasePlugin);

server.get('/users', async (request, reply) => {
  const users = await request.server.db.user.findMany();
  return users;
});

The same augmentation technique works for custom decorators on the request object (like request.user for authentication). Extend the FastifyRequest interface in a similar declare module block, and Fastify will merge the types.

Using Hooks and Middleware with Types

Hooks such as onRequest, preHandler, and onSend also receive typed request and reply objects. When you add a hook inside a route context that has a schema, the hook shares the same typed request.

server.get('/secure/data', {
  onRequest: async (request, reply) => {
    // request is fully typed if a schema is defined on the route
    const authHeader = request.headers.authorization;
    // validate token...
  },
  schema: {
    response: {
      200: Type.Object({ data: Type.String() }),
    },
  },
  async (request, reply) => {
    return { data: 'secret' };
  },
});

For global hooks, consider using a plugin that adds them with instance.addHook. The types flow through encapsulation automatically.

Best Practices for Strongly Typed Fastify Applications

Example: Complete Typed Plugin with Routes

Let’s put everything together in a small but realistic module. This plugin defines a typed route for user creation and another for retrieval, using a shared Prisma client.

import { FastifyInstance } from 'fastify';
import { Type } from '@sinclair/typebox';
import fp from 'fastify-plugin';
import { PrismaClient } from '@prisma/client';

// Augment FastifyInstance to include db
declare module 'fastify' {
  interface FastifyInstance {
    db: PrismaClient;
  }
}

async function userRoutes(instance: FastifyInstance) {
  // POST /users
  instance.post('/users', {
    schema: {
      body: Type.Object({
        name: Type.String(),
        email: Type.String({ format: 'email' }),
      }),
      response: {
        201: Type.Object({
          id: Type.Number(),
          name: Type.String(),
          email: Type.String(),
        }),
      },
    },
    async (request, reply) => {
      const { name, email } = request.body;
      const user = await instance.db.user.create({
        data: { name, email },
      });
      reply.code(201).send(user);
    },
  });

  // GET /users/:id
  instance.get('/users/:id', {
    schema: {
      params: Type.Object({
        id: Type.Number(), // Fastify will coerce string to number if possible
      }),
      response: {
        200: Type.Object({
          id: Type.Number(),
          name: Type.String(),
          email: Type.String(),
        }),
      },
    },
    async (request, reply) => {
      const user = await instance.db.user.findUnique({
        where: { id: request.params.id },
      });
      if (!user) {
        return reply.code(404).send({ error: 'User not found' });
      }
      return user;
    },
  });
}

export default fp(userRoutes);

Then register it in your main server file along with the database plugin:

import Fastify from 'fastify';
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
import databasePlugin from './plugins/database.js';
import userRoutes from './routes/users.js';

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

server.register(databasePlugin);
server.register(userRoutes, { prefix: '/api' });

server.listen({ port: 3000 });

Every route is typed, the database client is available everywhere, and the compiler catches mismatches long before runtime.

Conclusion

Combining Fastify with TypeScript and a type provider like TypeBox transforms your Node.js API development. You get the performance and simplicity of Fastify, plus the rock-solid safety net of static types — all without writing redundant type definitions. By defining schemas once, you gain runtime validation, automatic type inference, and self-documenting contracts. Following the patterns shown here — typed routes, module augmentation for decorators, strict TypeScript settings, and thin handlers — you can build maintainable, scalable, and strongly typed Fastify applications that grow with your team.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles