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:
- Type-safe request and reply objects – Query strings, route parameters, headers, and bodies are typed automatically based on your JSON schemas.
- Runtime validation aligned with static types – Schemas validate incoming data; TypeScript catches mismatches during development.
- Self-documenting routes – Schemas act as a single source of truth, making your API contracts explicit.
- Enhanced IDE support – Autocompletion, refactoring, and instant error feedback.
- Plugin and decorator safety – TypeScript’s module augmentation ensures custom properties are recognised across the entire instance.
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
- Use TypeBox for all schemas – It gives you runtime validation and static types from a single definition. Avoid raw JSON Schema objects when you want type inference; TypeBox with
@fastify/type-provider-typeboxis the recommended stack. - Enable strict TypeScript options –
strict: trueandnoImplicitAnycatch many bugs early. Fastify's type definitions are designed to work with strict mode. - Keep route handlers thin – Extract business logic into separate functions or services. This makes the handler's type contract clearer and testing easier.
- Leverage encapsulation – Define schemas and types close to the route. Fastify's plugin encapsulation ensures that type scopes remain isolated when needed.
- Augment modules for decorators – Never use type assertions (
as) for custom properties. Always augment the Fastify module so the entire codebase sees the types. - Use
fastify-pluginfor reusable plugins – It ensures the plugin is loaded correctly and its types are propagated. Wrap every plugin withfp()unless you explicitly want encapsulation. - Validate, don’t trust – Even with strong types, always define schemas for external input. Fastify validates automatically, but you can also call
request.validate()if needed.
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.