How to Use Fastify with TypeScript
TypeScript checks your code at compile time, but once your API starts accepting requests, all those types disappear. A client sends { email: "user@example.com" } when your handler expects { email: string; password: string }, and your app crashes trying to access password.length. TypeScript can't protect you here. Type information doesn't exist at runtime.
Fastify fixes this with schema-first validation. You define JSON schemas that check incoming requests before they reach your handlers. TypeScript automatically infers types from those schemas. One schema gives you both runtime validation and compile-time safety. This guide walks you through building Node.js APIs with Fastify and TypeScript, from basic setup to patterns that catch data problems before they become production bugs.
Setting Up Fastify with TypeScript
To start a Fastify project with TypeScript, install a few dependencies and configure your compiler. Fastify has strong TypeScript support built in:
npm init -y
npm install fastify
npm install --save-dev typescript @types/node
Initialize TypeScript and configure your tsconfig.json:
npx tsc --init
Update your tsconfig.json with these settings. Fastify requires target to be es2017 or higher:
{
"compilerOptions": {
"target": "es2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
Create your first Fastify server with proper TypeScript imports:
// src/server.ts
import fastify from 'fastify';
const server = fastify({
logger: true // Fastify includes built-in logging
});
server.get('/ping', async (request, reply) => {
return { message: 'pong' };
});
const start = async () => {
try {
await server.listen({ port: 8080 });
console.log('Server listening on port 8080');
} catch (err) {
server.log.error(err);
process.exit(1);
}
};
start().catch((err) => {
console.error('Failed to start server:', err);
process.exit(1);
});
Add these scripts to your package.json:
{
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
}
}
Important: When using Fastify with TypeScript, always use import/from syntax. Using require() will import Fastify but won't resolve types properly. You'll get confusing errors later.
For development, install tsx for automatic reloading:
npm install --save-dev tsx
Now you can run npm run dev for hot reloading during development, and npm run build && npm start for production builds.
Note: Node.js now has experimental native TypeScript support (using --experimental-strip-types), but as of 2026, tsx still provides a better developer experience with more features like watch mode and better error reporting.
Typing Routes and Handlers in Fastify
Fastify's route handlers get typed through generic parameters that define the shape of your request and response. This gives you type safety across params, query strings, body, and headers:
// src/routes/users.ts
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
// Define the shape of your route data
interface GetUserParams {
userId: string;
}
interface CreateUserBody {
name: string;
email: string;
role?: 'admin' | 'user';
}
interface UserResponse {
id: string;
name: string;
email: string;
role: string;
createdAt: Date;
}
export async function userRoutes(fastify: FastifyInstance) {
// GET /users/:userId - Typed route parameters
fastify.get<{ Params: GetUserParams }>(
'/users/:userId',
async (request, reply) => {
const { userId } = request.params; // TypeScript knows this is a string
// In a real app, fetch from database
const user: UserResponse = {
id: userId,
name: 'John Doe',
email: 'john@example.com',
role: 'user',
createdAt: new Date()
};
return user;
}
);
// POST /users - Typed request body
fastify.post<{ Body: CreateUserBody }>(
'/users',
async (request, reply) => {
const { name, email, role = 'user' } = request.body; // Fully typed
const newUser: UserResponse = {
id: crypto.randomUUID(),
name,
email,
role,
createdAt: new Date()
};
reply.status(201);
return newUser;
}
);
// GET /users - Typed query parameters
fastify.get<{ Querystring: { role?: string; limit?: number } }>(
'/users',
async (request, reply) => {
const { role, limit = 10 } = request.query; // TypeScript infers types
// Filter users by role if provided
const users: UserResponse[] = [
{
id: '1',
name: 'Alice',
email: 'alice@example.com',
role: 'admin',
createdAt: new Date()
}
];
return users.slice(0, limit);
}
);
}
To use these routes in your main server file:
// src/server.ts
import fastify from 'fastify';
import { userRoutes } from './routes/users';
const server = fastify({ logger: true });
// Register route handlers
server.register(userRoutes);
const start = async () => {
try {
await server.listen({ port: 8080 });
} catch (err) {
server.log.error(err);
process.exit(1);
}
};
start().catch((err) => {
console.error('Failed to start server:', err);
process.exit(1);
});
This approach uses interfaces to define the shape of your API data, giving you autocomplete and type checking throughout your route handlers.
Schema Validation with Type Providers
Fastify can automatically infer types from JSON schemas. Instead of writing types manually, you define schemas and TypeScript figures out the types for you.
Why schemas matter: TypeScript's type system only exists at compile time. Once your code runs, all type information is erased. Schema validation acts as your runtime safety net. When you define a schema, Fastify checks incoming requests against it before they reach your handler, catching malformed data that TypeScript can't protect against.
Without schemas, this would type-check fine but crash at runtime:
interface User {
name: string;
age: number;
}
function greetUser(user: User) {
return `Hello ${user.name.toUpperCase()}`; // Crashes if name is undefined!
}
// Client sends: { age: 25 } (missing name)
// TypeScript doesn't catch this - your handler crashes in production
With schemas, Fastify rejects invalid requests before your handler runs. You get runtime protection to go with TypeScript's compile-time checks.
npm install @fastify/type-provider-json-schema-to-ts
Here's how to use type providers for schema-first development:
// src/routes/products.ts
import { FastifyInstance } from 'fastify';
import { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts';
export async function productRoutes(fastify: FastifyInstance) {
// Enable type provider for this plugin
const server = fastify.withTypeProvider<JsonSchemaToTsProvider>();
server.get('/products/:productId', {
schema: {
params: {
type: 'object',
properties: {
productId: { type: 'string' }
},
required: ['productId']
},
querystring: {
type: 'object',
properties: {
includeReviews: { type: 'boolean' },
currency: { type: 'string', enum: ['USD', 'EUR', 'GBP'] }
}
},
response: {
200: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
price: { type: 'number' },
currency: { type: 'string' },
inStock: { type: 'boolean' }
},
required: ['id', 'name', 'price', 'currency', 'inStock']
}
}
}
}, async (request, reply) => {
// TypeScript infers all types from the schema
const { productId } = request.params; // string
const { includeReviews, currency = 'USD' } = request.query; // boolean | undefined, string | undefined
// The return type is also checked against the schema
return {
id: productId,
name: 'Widget',
price: 29.99,
currency,
inStock: true
};
});
server.post('/products', {
schema: {
body: {
type: 'object',
properties: {
name: { type: 'string', minLength: 1 },
description: { type: 'string' },
price: { type: 'number', minimum: 0 },
tags: {
type: 'array',
items: { type: 'string' }
}
},
required: ['name', 'price']
},
response: {
201: {
type: 'object',
properties: {
id: { type: 'string' },
createdAt: { type: 'string', format: 'date-time' }
},
required: ['id', 'createdAt']
}
}
}
}, async (request, reply) => {
const { name, description, price, tags = [] } = request.body; // All typed from schema
// Fastify validates the request automatically
// Invalid requests never reach this handler
const product = {
id: crypto.randomUUID(),
createdAt: new Date().toISOString()
};
reply.status(201);
return product;
});
}
The type provider gives you several benefits:
- Automatic validation: Invalid requests are rejected before reaching your handler
- Type inference: No need to manually write TypeScript interfaces
- Single source of truth: Your schema defines both runtime validation and compile-time types
- Response serialization: Fastify speeds up JSON serialization based on your schema
This pattern works well when building APIs that need both runtime validation and type safety. The schema acts as a contract that's enforced at runtime and checked at compile time.
Using TypeBox for Type Providers
While json-schema-to-ts works well, TypeBox is now the go-to type provider for most Fastify projects in 2026. It has better editor support and a cleaner API:
npm install @fastify/type-provider-typebox @sinclair/typebox
Here's how to use TypeBox with Fastify:
// src/routes/orders.ts
import { FastifyInstance } from 'fastify';
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
import { Type } from '@sinclair/typebox';
export async function orderRoutes(fastify: FastifyInstance) {
const server = fastify.withTypeProvider<TypeBoxTypeProvider>();
server.post('/orders', {
schema: {
body: Type.Object({
customerId: Type.String({ format: 'uuid' }),
items: Type.Array(Type.Object({
productId: Type.String(),
quantity: Type.Number({ minimum: 1 })
})),
shippingAddress: Type.Object({
street: Type.String(),
city: Type.String(),
postalCode: Type.String(),
country: Type.String({ minLength: 2, maxLength: 2 })
})
}),
response: {
201: Type.Object({
orderId: Type.String({ format: 'uuid' }),
total: Type.Number(),
estimatedDelivery: Type.String({ format: 'date-time' })
})
}
}
}, async (request, reply) => {
// TypeScript automatically infers all types from the schema
const { customerId, items, shippingAddress } = request.body;
const total = items.reduce((sum, item) => sum + item.quantity * 29.99, 0);
reply.status(201);
return {
orderId: crypto.randomUUID(),
total,
estimatedDelivery: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString()
};
});
}
TypeBox gives you better autocomplete and catches more schema errors at compile time. Choose json-schema-to-ts if you already have JSON schemas. Pick TypeBox for new projects where you want the best developer experience.
Creating Type-Safe Plugins
Fastify's plugin system lets you wrap up functionality and share it across your application. With TypeScript, you can extend Fastify's request and reply objects with custom properties:
// src/plugins/auth.ts
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import fp from 'fastify-plugin';
// Extend the FastifyRequest interface using declaration merging
// Declaration merging lets you add properties to existing interfaces.
// You declare a module with the same name, and TypeScript combines
// these declarations automatically. You get type safety across your app.
declare module 'fastify' {
interface FastifyRequest {
currentUser?: {
id: string;
email: string;
role: 'admin' | 'user';
};
}
interface FastifyReply {
sendUnauthorized: () => void;
}
}
export interface AuthPluginOptions {
secretKey: string;
}
async function authPlugin(
fastify: FastifyInstance,
options: AuthPluginOptions
) {
// Add a utility method to reply objects
fastify.decorateReply('sendUnauthorized', function() {
this.status(401).send({ error: 'Unauthorized' });
});
// Add an authentication hook
fastify.addHook('onRequest', async (request, reply) => {
const authHeader = request.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.substring(7);
// In a real app, validate the token with your secret key
// For now, we'll just decode a mock user
try {
request.currentUser = {
id: '123',
email: 'user@example.com',
role: 'user'
};
} catch (err) {
// Invalid token - currentUser remains undefined
}
}
});
}
// Export using fastify-plugin to ensure proper encapsulation
export default fp(authPlugin, {
name: 'auth-plugin',
fastify: '5.x'
});
Now you can use this plugin throughout your application:
// src/server.ts
import fastify from 'fastify';
import authPlugin from './plugins/auth';
const server = fastify({ logger: true });
// Register the plugin
server.register(authPlugin, {
secretKey: process.env.JWT_SECRET || 'dev-secret'
});
// Use the extended request/reply in routes
server.get('/protected', async (request, reply) => {
if (!request.currentUser) {
// TypeScript knows about this custom method
return reply.sendUnauthorized();
}
// TypeScript knows currentUser exists here
return {
message: `Hello, ${request.currentUser.email}!`,
role: request.currentUser.role
};
});
The key here is declaration merging. By declaring a module with the same name ('fastify'), you can extend the built-in interfaces. TypeScript learns about your custom properties and keeps them type-safe across your entire application.
For more complex authentication patterns, you might want to integrate with Convex's authentication system for built-in user management and session handling.
Advanced Typing Patterns
As your API grows, you'll want to create reusable patterns for common operations. Here are some TypeScript techniques for Fastify:
Using TypeScript 5.x Features
Modern TypeScript has features that make API development safer. Here's how to use them with Fastify:
// src/config/api-config.ts
// Using 'satisfies' for better type inference (TypeScript 4.9+)
const apiConfig = {
version: 'v1',
endpoints: {
users: '/users',
products: '/products',
orders: '/orders'
},
timeout: 5000,
maxRetries: 3
} satisfies Record<string, string | number | Record<string, string>>;
// Now you get autocomplete AND type checking
const userEndpoint = apiConfig.endpoints.users; // Type: string (not string | number | ...)
const timeout = apiConfig.timeout; // Type: number
// Const type parameters (TypeScript 5.0+)
function createApiPath<const T extends string>(path: T) {
return `/api/v1${path}` as const;
}
// Type is the exact literal, not just 'string'
const userPath = createApiPath('/users'); // Type: '/api/v1/users'
const productPath = createApiPath('/products'); // Type: '/api/v1/products'
// This enables type-safe routing
type ApiPath = ReturnType<typeof createApiPath<'/users' | '/products' | '/orders'>>;
export async function typedRoutes(fastify: FastifyInstance) {
// Using satisfies with route schemas
const userSchema = {
params: {
type: 'object',
properties: {
userId: { type: 'string', format: 'uuid' }
},
required: ['userId']
},
response: {
200: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
email: { type: 'string' }
},
required: ['id', 'name', 'email']
}
}
} satisfies FastifySchema;
fastify.get('/users/:userId', { schema: userSchema }, async (request, reply) => {
// Types are inferred from schema
return {
id: request.params.userId,
name: 'Alice',
email: 'alice@example.com'
};
});
}
The satisfies operator checks that your object matches a type without widening the type. You keep precise literal types while still getting type safety.
Generic Route Handlers
You can create generic handlers that work with different data types:
// src/utils/crud.ts
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
interface Entity {
readonly id: string;
readonly createdAt: Date;
readonly updatedAt: Date;
}
interface CrudOptions<T extends Entity> {
findAll: () => Promise<T[]>;
findById: (id: string) => Promise<T | null>;
create: (data: Omit<T, 'id' | 'createdAt' | 'updatedAt'>) => Promise<T>;
update: (id: string, data: Partial<T>) => Promise<T | null>;
delete: (id: string) => Promise<boolean>;
}
export function createCrudRoutes<T extends Entity>(
fastify: FastifyInstance,
basePath: string,
options: CrudOptions<T>
) {
// GET /resource
fastify.get(basePath, async (request, reply) => {
const entities = await options.findAll();
return entities;
});
// GET /resource/:id
fastify.get<{ Params: { id: string } }>(
`${basePath}/:id`,
async (request, reply) => {
const entity = await options.findById(request.params.id);
if (!entity) {
reply.status(404);
return { error: 'Not found' };
}
return entity;
}
);
// POST /resource
fastify.post<{ Body: Omit<T, 'id' | 'createdAt' | 'updatedAt'> }>(
basePath,
async (request, reply) => {
const newEntity = await options.create(request.body);
reply.status(201);
return newEntity;
}
);
// PATCH /resource/:id
fastify.patch<{ Params: { id: string }; Body: Partial<T> }>(
`${basePath}/:id`,
async (request, reply) => {
const updated = await options.update(request.params.id, request.body);
if (!updated) {
reply.status(404);
return { error: 'Not found' };
}
return updated;
}
);
// DELETE /resource/:id
fastify.delete<{ Params: { id: string } }>(
`${basePath}/:id`,
async (request, reply) => {
const deleted = await options.delete(request.params.id);
if (!deleted) {
reply.status(404);
return { error: 'Not found' };
}
reply.status(204).send();
}
);
}
Use this pattern to quickly create type-safe CRUD routes:
// src/routes/articles.ts
import { FastifyInstance } from 'fastify';
import { createCrudRoutes } from '../utils/crud';
interface Article {
id: string;
title: string;
content: string;
authorId: string;
createdAt: Date;
updatedAt: Date;
}
export async function articleRoutes(fastify: FastifyInstance) {
createCrudRoutes<Article>(fastify, '/articles', {
findAll: async () => {
// Fetch from database
return [];
},
findById: async (id) => {
// Fetch from database
return null;
},
create: async (data) => {
// Create in database
return {
...data,
id: crypto.randomUUID(),
createdAt: new Date(),
updatedAt: new Date()
};
},
update: async (id, data) => {
// Update in database
return null;
},
delete: async (id) => {
// Delete from database
return false;
}
});
}
This uses generics and utility types like Omit and Partial for flexible, reusable code.
Custom Error Types
Create a hierarchy of error types for better error handling:
// src/errors/api-errors.ts
export class ApiError extends Error {
constructor(
message: string,
public statusCode: number,
public code: string
) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
export class NotFoundError extends ApiError {
constructor(resource: string, id?: string) {
super(
id ? `${resource} with ID ${id} not found` : `${resource} not found`,
404,
'NOT_FOUND'
);
}
}
export class ValidationError extends ApiError {
constructor(message: string, public fields?: Record<string, string>) {
super(message, 400, 'VALIDATION_ERROR');
}
}
export class UnauthorizedError extends ApiError {
constructor(message: string = 'Unauthorized') {
super(message, 401, 'UNAUTHORIZED');
}
}
Set up a global error handler:
// src/server.ts
import fastify from 'fastify';
import { ApiError } from './errors/api-errors';
const server = fastify({ logger: true });
// Custom error handler
server.setErrorHandler((error, request, reply) => {
// Log the error
request.log.error(error);
// Handle our custom API errors
if (error instanceof ApiError) {
return reply.status(error.statusCode).send({
error: {
code: error.code,
message: error.message,
...(error instanceof ValidationError && error.fields
? { fields: error.fields }
: {})
}
});
}
// Handle validation errors from Fastify schemas
if (error.validation) {
return reply.status(400).send({
error: {
code: 'VALIDATION_ERROR',
message: 'Request validation failed',
details: error.validation
}
});
}
// Handle unexpected errors
reply.status(error.statusCode || 500).send({
error: {
code: 'INTERNAL_ERROR',
message: error.message || 'An unexpected error occurred'
}
});
});
Now you can throw typed errors in your routes:
// src/routes/users.ts
import { NotFoundError, ValidationError } from '../errors/api-errors';
export async function userRoutes(fastify: FastifyInstance) {
fastify.get<{ Params: { userId: string } }>(
'/users/:userId',
async (request, reply) => {
const user = await findUserById(request.params.userId);
if (!user) {
throw new NotFoundError('User', request.params.userId);
}
return user;
}
);
fastify.post<{ Body: { email: string; password: string } }>(
'/users',
async (request, reply) => {
const { email, password } = request.body;
// Custom validation
if (password.length < 8) {
throw new ValidationError('Invalid user data', {
password: 'Password must be at least 8 characters'
});
}
// Create user...
return { id: '123', email };
}
);
}
This approach uses classes to create an error hierarchy that's type-safe and expressive. You get consistent error responses across your API.
Typing Fastify Hooks
Fastify hooks let you run code at different points in the request lifecycle. Here's how to type them properly:
// src/plugins/logging.ts
import { FastifyInstance, FastifyRequest, FastifyReply, HookHandlerDoneFunction } from 'fastify';
import fp from 'fastify-plugin';
async function loggingPlugin(fastify: FastifyInstance) {
// onRequest runs before any other hook
fastify.addHook('onRequest', async (request, reply) => {
request.log.info({ url: request.url }, 'Incoming request');
});
// preHandler runs after parsing but before the route handler
fastify.addHook('preHandler', async (request, reply) => {
// Add timing information
const start = Date.now();
reply.addHook('onSend', async (request, reply, payload) => {
const duration = Date.now() - start;
reply.header('X-Response-Time', `${duration}ms`);
return payload;
});
});
// onResponse runs after the response is sent
fastify.addHook('onResponse', async (request, reply) => {
request.log.info(
{
url: request.url,
statusCode: reply.statusCode,
responseTime: reply.getHeader('X-Response-Time')
},
'Request completed'
);
});
// onError runs when an error occurs
fastify.addHook('onError', async (request, reply, error) => {
request.log.error({ err: error, url: request.url }, 'Request error');
});
}
export default fp(loggingPlugin);
Hooks can also be registered per-route for more granular control:
// src/routes/admin.ts
import { FastifyInstance } from 'fastify';
import { UnauthorizedError } from '../errors/api-errors';
export async function adminRoutes(fastify: FastifyInstance) {
fastify.get('/admin/stats', {
// Add a preHandler hook just for this route
preHandler: async (request, reply) => {
if (request.currentUser?.role !== 'admin') {
throw new UnauthorizedError('Admin access required');
}
}
}, async (request, reply) => {
return {
totalUsers: 1234,
activeToday: 567
};
});
}
Common Problems and How to Fix Them
Here are the most common issues developers hit when using Fastify with TypeScript:
Import Syntax Issues
Problem: You're getting type errors even though Fastify is installed.
// ❌ Wrong - types won't resolve
const fastify = require('fastify');
// ✅ Correct - use ES modules
import fastify from 'fastify';
Why this happens: Fastify's type definitions work with ES module imports. Using require() imports the runtime code but TypeScript can't resolve the types properly.
Error Handler Type Inference
Problem: TypeScript complains about error types in setErrorHandler.
// ❌ TypeScript might infer the wrong error type
server.setErrorHandler((error, request, reply) => {
// TypeScript assumes `error` is always FastifyError
// But errors from your code might have different shapes
console.log(error.statusCode); // Might not exist
});
Solution: Use proper type guards for better type narrowing:
// Define a type guard for clearer type narrowing
interface FastifyError extends Error {
statusCode?: number;
validation?: unknown[];
}
function isFastifyError(error: unknown): error is FastifyError {
return error instanceof Error && ('statusCode' in error || 'validation' in error);
}
// ✅ Use the type guard for safe error handling
server.setErrorHandler((error, request, reply) => {
if (isFastifyError(error)) {
const statusCode = error.statusCode ?? 500;
reply.status(statusCode).send({
error: { message: error.message }
});
} else {
reply.status(500).send({
error: { message: 'Internal Server Error' }
});
}
});
Plugin Registration Type Problems
Problem: TypeScript errors when registering plugins in Fastify 4.x.
// ❌ Might cause type errors in Fastify 4.x
fastify.register(myPlugin, { someOption: true });
Solution: Ensure your plugin is properly typed with FastifyPluginAsync or FastifyPluginCallback:
// ✅ Explicitly type your plugin
import { FastifyPluginAsync } from 'fastify';
const myPlugin: FastifyPluginAsync<{ someOption: boolean }> = async (
fastify,
options
) => {
// Plugin implementation
};
// Now registration works correctly
fastify.register(myPlugin, { someOption: true });
Missing Type Declarations
Problem: Build fails with "Cannot find @types/node" even though it's installed.
Solution: Make sure @types/node is in your dependencies (not just devDependencies) for production builds:
# Install as a regular dependency for deployment
npm install @types/node
Also verify your tsconfig.json includes the right type definitions:
{
"compilerOptions": {
"types": ["node"],
"moduleResolution": "node"
}
}
Generic Type Complexity
Problem: TypeScript complains about complex generic types with routes.
// ❌ Complex generics can be hard to read
fastify.get<{
Querystring: { search: string; page: number };
Params: { id: string };
Headers: { authorization: string };
Body: never;
}>('/users/:id', async (request, reply) => {
// ...
});
Solution: Extract types into interfaces for clarity:
// ✅ Much cleaner
interface GetUserRequest {
Querystring: { search: string; page: number };
Params: { id: string };
Headers: { authorization: string };
}
fastify.get<GetUserRequest>('/users/:id', async (request, reply) => {
const { id } = request.params;
const { search, page } = request.query;
// ...
});
Schema Validation Not Catching Errors
Problem: Invalid requests are reaching your handlers.
Solution: Make sure your schema includes required fields:
// ❌ Without 'required', all fields are optional
schema: {
body: {
type: 'object',
properties: {
email: { type: 'string' },
password: { type: 'string' }
}
}
}
// ✅ Specify required fields
schema: {
body: {
type: 'object',
properties: {
email: { type: 'string', format: 'email' },
password: { type: 'string', minLength: 8 }
},
required: ['email', 'password']
}
}
Building Production-Ready APIs with Fastify and TypeScript
Let's put everything together with a complete example. This shows how to build a blog API with authentication, validation, and error handling:
// src/server.ts
import fastify from 'fastify';
import { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts';
import authPlugin from './plugins/auth';
import { articleRoutes } from './routes/articles';
import { ApiError } from './errors/api-errors';
const server = fastify({
logger: {
level: process.env.LOG_LEVEL || 'info'
}
}).withTypeProvider<JsonSchemaToTsProvider>();
// Register plugins
server.register(authPlugin, {
secretKey: process.env.JWT_SECRET || 'dev-secret'
});
// Register routes
server.register(articleRoutes, { prefix: '/api/v1' });
// Health check endpoint
server.get('/health', async () => ({
status: 'ok',
timestamp: new Date().toISOString()
}));
// Global error handler
server.setErrorHandler((error, request, reply) => {
request.log.error(error);
if (error instanceof ApiError) {
return reply.status(error.statusCode).send({
error: {
code: error.code,
message: error.message
}
});
}
if (error.validation) {
return reply.status(400).send({
error: {
code: 'VALIDATION_ERROR',
message: 'Request validation failed',
details: error.validation
}
});
}
reply.status(500).send({
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred'
}
});
});
const start = async () => {
try {
const port = parseInt(process.env.PORT || '8080');
await server.listen({ port, host: '0.0.0.0' });
server.log.info(`Server listening on port ${port}`);
} catch (err) {
server.log.error(err);
process.exit(1);
}
};
start().catch((err) => {
console.error('Failed to start server:', err);
process.exit(1);
});
For more complex applications, consider Convex for your backend. Convex gives you real-time data sync, automatic schema validation, and built-in TypeScript support that pairs well with Fastify's performance:
// src/routes/articles.ts with Convex integration
import { FastifyInstance } from 'fastify';
import { ConvexHttpClient } from 'convex/browser';
import { api } from '../convex/_generated/api';
const convex = new ConvexHttpClient(process.env.CONVEX_URL!);
export async function articleRoutes(fastify: FastifyInstance) {
fastify.get('/articles', async (request, reply) => {
// Fetch articles from Convex with full type safety
const articles = await convex.query(api.articles.list);
return articles;
});
fastify.post('/articles', {
schema: {
body: {
type: 'object',
properties: {
title: { type: 'string', minLength: 1 },
content: { type: 'string' },
tags: { type: 'array', items: { type: 'string' } }
},
required: ['title', 'content']
}
}
}, async (request, reply) => {
const { title, content, tags } = request.body;
// Store in Convex with automatic validation
const articleId = await convex.mutation(api.articles.create, {
title,
content,
tags: tags || []
});
reply.status(201);
return { id: articleId };
});
}
This pattern combines Fastify's fast request handling with Convex's type-safe database operations and real-time features. You get fast API responses and a solid backend with minimal config.
Building Type-Safe APIs with Fastify
Fastify and TypeScript work together to help you build APIs that are both fast and reliable. Here's what to remember:
- Use ES module imports (
import/from) instead ofrequire()so types resolve correctly - Use type providers for automatic type inference from JSON schemas, cutting out manual type definitions
- Extend Fastify with plugins using declaration merging to add custom properties while keeping type safety
- Create reusable patterns with generics and utility types to avoid code duplication
- Handle errors consistently with custom error classes and a global error handler
- Validate at the door with schemas to catch invalid data before it reaches your handlers
While Fastify's performance edge over Express has shrunk in recent years, its built-in TypeScript support, schema validation, and plugin architecture make it a strong choice for building type-safe Node.js APIs. Compile-time type checking plus runtime validation helps you catch bugs before they reach production.
Need a type-safe backend with real-time features? Convex gives you automatic schema validation, TypeScript support, and real-time data sync that pairs well with Fastify's performance.