Fastify con TypeScript
Fastify è uno dei framework HTTP più performanti dell'ecosistema Node.js. Progettato con un'architettura a plugin, un sistema di serializzazione basato su JSON Schema e un overhead minimo nella gestione delle richieste, rappresenta una scelta solida per la costruzione di API REST moderne. Quando viene abbinato a TypeScript, il risultato è un ambiente di sviluppo in cui la type safety accompagna ogni fase del progetto: dalla definizione delle rotte alla validazione dei payload, dalla gestione degli errori all'integrazione con database e servizi esterni.
Questo articolo esplora in profondità l'uso di Fastify con TypeScript, partendo dalla configurazione iniziale del progetto fino ad arrivare a pattern avanzati come la creazione di plugin tipizzati, la validazione con JSON Schema inferito e la gestione strutturata degli errori.
Inizializzazione del progetto
La prima cosa da fare è creare la struttura del progetto e installare le dipendenze necessarie.
Fastify fornisce i propri type definitions integrati nel pacchetto principale, quindi non servono
pacchetti @types separati per il framework stesso.
# Creazione della cartella e inizializzazione
mkdir fastify-ts-app
cd fastify-ts-app
npm init -y
# Dipendenze di produzione
npm install fastify @fastify/cors @fastify/sensible
# Dipendenze di sviluppo
npm install -D typescript tsx @types/node
Il pacchetto tsx permette di eseguire file TypeScript direttamente, senza un passaggio
di compilazione manuale. È molto comodo in fase di sviluppo. Il file tsconfig.json
deve essere configurato con attenzione per sfruttare al meglio i tipi di Fastify.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"strict": true,
"outDir": "dist",
"rootDir": "src",
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"]
}
L'opzione "strict": true è fondamentale: attiva strictNullChecks,
noImplicitAny e tutte le altre flag di rigore che rendono il type checking davvero
utile. Senza di essa, molti dei vantaggi dell'integrazione tra Fastify e TypeScript andrebbero persi.
Struttura del progetto
Una struttura ordinata facilita la manutenzione e rende esplicita la separazione delle responsabilità. Ecco un layout che funziona bene per progetti di dimensioni medie.
src/
├── server.ts
├── app.ts
├── routes/
│ ├── users.ts
│ └── products.ts
├── plugins/
│ ├── database.ts
│ └── authentication.ts
├── schemas/
│ ├── user.ts
│ └── product.ts
├── services/
│ ├── userService.ts
│ └── productService.ts
└── types/
└── index.ts
La separazione tra server.ts e app.ts è intenzionale: il file
app.ts costruisce e configura l'istanza Fastify, mentre server.ts
la avvia. Questo pattern permette di importare l'app nei test senza avviare il server reale.
Creazione dell'istanza Fastify
Il file app.ts è il cuore dell'applicazione. Qui si crea l'istanza del server,
si registrano i plugin e si montano le rotte.
import Fastify, { FastifyInstance } from "fastify";
import cors from "@fastify/cors";
import sensible from "@fastify/sensible";
export async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({
// Abilita il logging strutturato con pino
logger: {
level: "info",
transport: {
target: "pino-pretty",
options: {
translateTime: "HH:MM:ss Z",
ignore: "pid,hostname",
},
},
},
});
// Plugin globali
await app.register(cors, {
origin: true,
});
await app.register(sensible);
// Rotta di controllo stato
app.get("/health", async () => {
return { status: "ok", timestamp: new Date().toISOString() };
});
return app;
}
Il plugin @fastify/sensible aggiunge metodi utili come
reply.notFound(), reply.badRequest() e altri shortcut per le risposte
HTTP più comuni. Questi metodi sono completamente tipizzati.
Il file server.ts si occupa esclusivamente dell'avvio.
import { buildApp } from "./app.js";
async function start(): Promise<void> {
const app = await buildApp();
const port = Number(process.env.PORT) || 3000;
const host = process.env.HOST || "0.0.0.0";
try {
await app.listen({ port, host });
} catch (error) {
app.log.error(error);
process.exit(1);
}
}
start();
In package.json conviene aggiungere gli script per lo sviluppo e la build.
{
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
}
}
Rotte tipizzate con Generic
Il punto di forza dell'integrazione tra Fastify e TypeScript sta nella possibilità di tipizzare
ogni aspetto di una rotta: i parametri dell'URL, la query string, il body della richiesta e la
risposta. Fastify espone un'interfaccia generica chiamata RouteGenericInterface
che accetta quattro proprietà: Params, Querystring, Body
e Reply.
import { FastifyInstance, RouteGenericInterface } from "fastify";
// Definizione dei tipi per la singola rotta
interface GetUserRoute extends RouteGenericInterface {
Params: {
id: string;
};
Reply: {
id: string;
name: string;
email: string;
createdAt: string;
};
}
interface CreateUserRoute extends RouteGenericInterface {
Body: {
name: string;
email: string;
password: string;
};
Reply: {
id: string;
name: string;
email: string;
createdAt: string;
};
}
interface ListUsersRoute extends RouteGenericInterface {
Querystring: {
page?: number;
limit?: number;
search?: string;
};
Reply: {
data: Array<{
id: string;
name: string;
email: string;
}>;
total: number;
page: number;
limit: number;
};
}
Questi tipi vengono poi passati come parametro generico al metodo della rotta. All'interno
dell'handler, request.params, request.body e request.query
saranno tipizzati automaticamente.
export async function userRoutes(app: FastifyInstance): Promise<void> {
app.get<GetUserRoute>("/:id", async (request, reply) => {
// request.params.id è di tipo string
const { id } = request.params;
const user = await findUserById(id);
if (!user) {
// Metodo fornito da @fastify/sensible
return reply.notFound(`Utente ${id} non trovato`);
}
return user;
});
app.post<CreateUserRoute>("/", async (request, reply) => {
// request.body è tipizzato con name, email, password
const { name, email, password } = request.body;
const user = await createUser({ name, email, password });
// Codice 201 per la creazione
reply.status(201);
return user;
});
app.get<ListUsersRoute>("/", async (request) => {
// I campi opzionali hanno il tipo corretto
const page = request.query.page ?? 1;
const limit = request.query.limit ?? 20;
const search = request.query.search;
const result = await listUsers({ page, limit, search });
return result;
});
}
Il compilatore TypeScript segnalerà un errore se si tenta di accedere a una proprietà inesistente
su request.params o se il valore restituito dall'handler non corrisponde al tipo
dichiarato in Reply.
Validazione con JSON Schema
Fastify utilizza JSON Schema internamente per validare le richieste in ingresso e per serializzare
le risposte in uscita. Questo meccanismo è uno dei motivi principali delle sue prestazioni superiori:
la serializzazione basata su schema è significativamente più veloce di JSON.stringify.
Per ottenere il massimo vantaggio con TypeScript, si può definire lo schema una sola volta e derivare il tipo da esso, evitando la duplicazione tra schema e interfaccia.
// Definizione dello schema come const per preservare i tipi literal
const createUserSchema = {
body: {
type: "object",
required: ["name", "email", "password"],
properties: {
name: { type: "string", minLength: 2, maxLength: 100 },
email: { type: "string", format: "email" },
password: { type: "string", minLength: 8 },
},
additionalProperties: false,
},
response: {
201: {
type: "object",
properties: {
id: { type: "string", format: "uuid" },
name: { type: "string" },
email: { type: "string" },
createdAt: { type: "string", format: "date-time" },
},
},
},
} as const;
Lo schema viene associato alla rotta tramite la proprietà schema. Fastify
validerà automaticamente ogni richiesta in ingresso e restituirà un errore 400 se il body
non rispetta il contratto definito.
app.post<CreateUserRoute>("/", {
schema: createUserSchema,
handler: async (request, reply) => {
// Il body è già validato a questo punto
const { name, email, password } = request.body;
const user = await createUser({ name, email, password });
reply.status(201);
return user;
},
});
Per progetti più grandi, è consigliabile usare librerie come @sinclair/typebox che
permettono di definire lo schema e il tipo TypeScript in un'unica dichiarazione.
import { Type, Static } from "@sinclair/typebox";
// Lo schema e il tipo vengono generati insieme
const UserBody = Type.Object({
name: Type.String({ minLength: 2, maxLength: 100 }),
email: Type.String({ format: "email" }),
password: Type.String({ minLength: 8 }),
});
// Il tipo TypeScript si deriva dallo schema
type UserBodyType = Static<typeof UserBody>;
const UserResponse = Type.Object({
id: Type.String({ format: "uuid" }),
name: Type.String(),
email: Type.String(),
createdAt: Type.String({ format: "date-time" }),
});
type UserResponseType = Static<typeof UserResponse>;
// Utilizzo nella rotta
app.post<{
Body: UserBodyType;
Reply: UserResponseType;
}>("/", {
schema: {
body: UserBody,
response: { 201: UserResponse },
},
handler: async (request, reply) => {
const { name, email, password } = request.body;
const user = await createUser({ name, email, password });
reply.status(201);
return user;
},
});
Con TypeBox, ogni modifica allo schema si riflette immediatamente sul tipo, eliminando la possibilità di disallineamenti.
Plugin tipizzati
Il sistema a plugin è l'architettura fondamentale di Fastify. Ogni plugin è una funzione asincrona che riceve l'istanza Fastify e un oggetto di opzioni. Con TypeScript, è possibile tipizzare sia le opzioni del plugin sia le decorazioni che il plugin aggiunge all'istanza.
Supponiamo di voler creare un plugin per la connessione al database. Il plugin decora l'istanza
Fastify con un oggetto db utilizzabile in tutte le rotte.
import { FastifyPluginAsync } from "fastify";
import fp from "fastify-plugin";
// Interfaccia delle opzioni del plugin
interface DatabasePluginOptions {
connectionString: string;
maxConnections?: number;
}
// Interfaccia del client database (esempio semplificato)
interface DatabaseClient {
query<T>(sql: string, params?: unknown[]): Promise<T[]>;
close(): Promise<void>;
}
// Dichiarazione di modulo per estendere i tipi di Fastify
declare module "fastify" {
interface FastifyInstance {
db: DatabaseClient;
}
}
const databasePlugin: FastifyPluginAsync<DatabasePluginOptions> = async (
app,
options
) => {
const { connectionString, maxConnections = 10 } = options;
// Creazione della connessione
const client = await createDatabaseClient(connectionString, maxConnections);
// Decorazione dell'istanza con il client
app.decorate("db", client);
// Chiusura della connessione allo spegnimento del server
app.addHook("onClose", async () => {
await client.close();
});
};
// fp() rende il plugin accessibile nell'intero albero dei plugin
export default fp(databasePlugin, {
name: "database",
});
La dichiarazione declare module "fastify" è il meccanismo chiave: estende
l'interfaccia FastifyInstance aggiungendo la proprietà db. Dopo la
registrazione del plugin, qualsiasi handler potrà accedere a app.db con il tipo
corretto.
Un altro esempio comune è un plugin di autenticazione che aggiunge informazioni sull'utente
autenticato all'oggetto request.
import { FastifyPluginAsync, FastifyRequest } from "fastify";
import fp from "fastify-plugin";
interface AuthenticatedUser {
id: string;
email: string;
role: "admin" | "editor" | "viewer";
}
// Estensione dell'interfaccia FastifyRequest
declare module "fastify" {
interface FastifyRequest {
currentUser: AuthenticatedUser | null;
}
}
const authPlugin: FastifyPluginAsync = async (app) => {
// Decorazione iniziale con null
app.decorateRequest("currentUser", null);
app.addHook("onRequest", async (request) => {
const token = request.headers.authorization?.replace("Bearer ", "");
if (!token) {
request.currentUser = null;
return;
}
try {
// Verifica e decodifica del token
const payload = await verifyToken(token);
request.currentUser = payload;
} catch {
request.currentUser = null;
}
});
};
export default fp(authPlugin, {
name: "authentication",
});
Dopo la registrazione di questo plugin, request.currentUser sarà disponibile in
ogni handler con il tipo AuthenticatedUser | null. Si può quindi creare un
preHandler riutilizzabile per proteggere le rotte.
import { FastifyReply, FastifyRequest } from "fastify";
// Guardia per rotte che richiedono autenticazione
export async function requireAuth(
request: FastifyRequest,
reply: FastifyReply
): Promise<void> {
if (!request.currentUser) {
reply.unauthorized("Token mancante o non valido");
return;
}
}
// Guardia per rotte riservate agli amministratori
export async function requireAdmin(
request: FastifyRequest,
reply: FastifyReply
): Promise<void> {
if (!request.currentUser) {
reply.unauthorized("Token mancante o non valido");
return;
}
if (request.currentUser.role !== "admin") {
reply.forbidden("Accesso riservato agli amministratori");
return;
}
}
// Utilizzo nelle rotte
app.get("/admin/dashboard", {
preHandler: [requireAuth, requireAdmin],
handler: async (request) => {
// request.currentUser è garantito non-null qui,
// ma TypeScript non lo sa ancora senza un type guard
const user = request.currentUser!;
return { message: `Benvenuto, ${user.email}` };
},
});
Hook e ciclo di vita
Fastify espone numerosi hook che intercettano le diverse fasi del ciclo di vita di una richiesta.
Tutti sono completamente tipizzati. I più utilizzati sono onRequest,
preHandler, onSend e onError.
import { FastifyInstance } from "fastify";
export async function lifecycleHooks(app: FastifyInstance): Promise<void> {
// Eseguito per ogni richiesta in ingresso
app.addHook("onRequest", async (request) => {
request.log.info({ url: request.url, method: request.method }, "Richiesta ricevuta");
});
// Eseguito dopo la serializzazione, prima dell'invio
app.addHook("onSend", async (request, reply, payload) => {
// Aggiunta di header personalizzati
reply.header("X-Request-Id", request.id);
return payload;
});
// Eseguito dopo che la risposta è stata inviata
app.addHook("onResponse", async (request, reply) => {
const duration = reply.elapsedTime;
request.log.info(
{ statusCode: reply.statusCode, duration: `${duration.toFixed(2)}ms` },
"Risposta inviata"
);
});
// Intercettazione degli errori
app.addHook("onError", async (request, reply, error) => {
request.log.error({ err: error, url: request.url }, "Errore nella richiesta");
});
}
Un aspetto importante degli hook Fastify è il loro incapsulamento: un hook registrato all'interno di un plugin si applica solo alle rotte di quel plugin e dei suoi sotto-plugin, non all'intera applicazione. Questo comportamento è coerente con il modello di encapsulation di Fastify e permette di isolare logiche specifiche senza effetti collaterali.
Gestione strutturata degli errori
Un'applicazione robusta ha bisogno di una strategia chiara per la gestione degli errori. Fastify
permette di definire un error handler globale con setErrorHandler, che è il punto
centrale per trasformare le eccezioni in risposte HTTP coerenti.
import { FastifyInstance, FastifyError } from "fastify";
// Errore applicativo personalizzato
class AppError extends Error {
public readonly statusCode: number;
public readonly code: string;
constructor(message: string, statusCode: number, code: string) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.name = "AppError";
}
}
// Sotto-classi per casi specifici
class NotFoundError extends AppError {
constructor(resource: string, id: string) {
super(`${resource} con id ${id} non trovato`, 404, "RESOURCE_NOT_FOUND");
}
}
class ConflictError extends AppError {
constructor(message: string) {
super(message, 409, "CONFLICT");
}
}
class ValidationError extends AppError {
constructor(message: string) {
super(message, 422, "VALIDATION_ERROR");
}
}
export function setupErrorHandler(app: FastifyInstance): void {
app.setErrorHandler(async (error: FastifyError | AppError, request, reply) => {
// Errori applicativi personalizzati
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
error: error.code,
message: error.message,
statusCode: error.statusCode,
});
}
// Errori di validazione generati da Fastify
if (error.validation) {
return reply.status(400).send({
error: "VALIDATION_ERROR",
message: "I dati inviati non sono validi",
details: error.validation,
statusCode: 400,
});
}
// Errori imprevisti
request.log.error({ err: error }, "Errore interno non gestito");
return reply.status(500).send({
error: "INTERNAL_SERVER_ERROR",
message: "Si è verificato un errore interno",
statusCode: 500,
});
});
}
L'uso di classi di errore personalizzate rende il codice degli handler molto pulito: basta lanciare l'eccezione appropriata e l'error handler si occupa del resto.
app.get<GetUserRoute>("/:id", async (request) => {
const user = await findUserById(request.params.id);
if (!user) {
// L'error handler trasformerà questo in una risposta 404 strutturata
throw new NotFoundError("Utente", request.params.id);
}
return user;
});
Serializzazione e performance
Uno dei motivi per cui Fastify è così veloce è il suo meccanismo di serializzazione. Quando si
definisce un response schema, Fastify usa fast-json-stringify per
generare una funzione di serializzazione ottimizzata in fase di avvio. Questa funzione è
molto più veloce del generico JSON.stringify perché conosce in anticipo la forma
dell'oggetto.
// Senza schema: Fastify usa JSON.stringify generico
app.get("/slow", async () => {
return { id: 1, name: "Test", timestamp: new Date().toISOString() };
});
// Con schema: Fastify genera una funzione di serializzazione ottimizzata
app.get("/fast", {
schema: {
response: {
200: {
type: "object",
properties: {
id: { type: "integer" },
name: { type: "string" },
timestamp: { type: "string" },
},
},
},
},
handler: async () => {
return { id: 1, name: "Test", timestamp: new Date().toISOString() };
},
});
La differenza di throughput tra le due versioni può essere significativa sotto carico. Lo schema di risposta ha anche un effetto collaterale utile: filtra automaticamente le proprietà non dichiarate. Se l'oggetto restituito dall'handler contiene campi aggiuntivi (come una password o dati interni), questi non verranno inclusi nella risposta. È una forma di sicurezza implicita che si ottiene gratuitamente definendo gli schema.
Registrazione modulare delle rotte
In un'applicazione reale, le rotte vanno organizzate in moduli separati. Fastify supporta nativamente questo pattern attraverso la registrazione di plugin con prefisso.
import { FastifyInstance } from "fastify";
// Modulo rotte utenti
async function userRoutes(app: FastifyInstance): Promise<void> {
app.get("/", async () => {
// GET /api/users
return { users: [] };
});
app.get("/:id", async (request) => {
// GET /api/users/:id
return { user: {} };
});
app.post("/", async (request) => {
// POST /api/users
return { created: true };
});
}
// Modulo rotte prodotti
async function productRoutes(app: FastifyInstance): Promise<void> {
app.get("/", async () => {
// GET /api/products
return { products: [] };
});
app.get("/:id", async (request) => {
// GET /api/products/:id
return { product: {} };
});
}
// Registrazione nell'app principale
export async function registerRoutes(app: FastifyInstance): Promise<void> {
// Ogni modulo riceve il proprio prefisso
app.register(userRoutes, { prefix: "/api/users" });
app.register(productRoutes, { prefix: "/api/products" });
}
Ogni chiamata a app.register crea un contesto incapsulato. I plugin e i decoratori
registrati all'interno di un modulo non sono visibili dagli altri, a meno che il plugin non venga
avvolto con fastify-plugin (come nel caso del plugin database visto prima).
Testing
Fastify fornisce il metodo inject che simula una richiesta HTTP senza aprire un
socket reale. È perfetto per i test unitari e di integrazione perché è veloce e non richiede
la gestione delle porte.
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { FastifyInstance } from "fastify";
import { buildApp } from "../app.js";
describe("User routes", () => {
let app: FastifyInstance;
beforeAll(async () => {
// Si costruisce l'app senza avviare il server
app = await buildApp();
await app.ready();
});
afterAll(async () => {
await app.close();
});
it("restituisce 200 per la rotta health", async () => {
const response = await app.inject({
method: "GET",
url: "/health",
});
expect(response.statusCode).toBe(200);
const body = response.json();
expect(body).toHaveProperty("status", "ok");
expect(body).toHaveProperty("timestamp");
});
it("restituisce 404 per un utente inesistente", async () => {
const response = await app.inject({
method: "GET",
url: "/api/users/non-esiste",
});
expect(response.statusCode).toBe(404);
const body = response.json();
expect(body.error).toBe("RESOURCE_NOT_FOUND");
});
it("crea un nuovo utente con dati validi", async () => {
const response = await app.inject({
method: "POST",
url: "/api/users",
payload: {
name: "Mario Rossi",
email: "mario@example.com",
password: "securepassword123",
},
});
expect(response.statusCode).toBe(201);
const body = response.json();
expect(body).toHaveProperty("id");
expect(body.name).toBe("Mario Rossi");
});
it("rifiuta la creazione con email non valida", async () => {
const response = await app.inject({
method: "POST",
url: "/api/users",
payload: {
name: "Test",
email: "non-una-email",
password: "securepassword123",
},
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toBe("VALIDATION_ERROR");
});
});
Il metodo inject restituisce un oggetto LightMyRequestResponse completamente
tipizzato, con metodi come .json() per il parsing automatico del body.
Pattern avanzato: service layer tipizzato
Per applicazioni di una certa complessità, è buona pratica separare la logica di business dagli handler delle rotte. Un service layer intermedio incapsula le operazioni sul database, la logica di validazione avanzata e le interazioni con servizi esterni.
// Interfaccia del servizio
interface IUserService {
findById(id: string): Promise<User | null>;
create(data: CreateUserInput): Promise<User>;
update(id: string, data: UpdateUserInput): Promise<User>;
delete(id: string): Promise<void>;
list(filters: UserFilters): Promise<PaginatedResult<User>>;
}
// Tipi di supporto
interface User {
id: string;
name: string;
email: string;
role: "admin" | "editor" | "viewer";
createdAt: Date;
updatedAt: Date;
}
interface CreateUserInput {
name: string;
email: string;
password: string;
}
interface UpdateUserInput {
name?: string;
email?: string;
}
interface UserFilters {
page: number;
limit: number;
search?: string;
role?: User["role"];
}
interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
// Implementazione concreta
class UserService implements IUserService {
constructor(private readonly db: DatabaseClient) {}
async findById(id: string): Promise<User | null> {
const rows = await this.db.query<User>(
"SELECT * FROM users WHERE id = $1",
[id]
);
return rows[0] ?? null;
}
async create(data: CreateUserInput): Promise<User> {
// Controllo duplicati sull'email
const existing = await this.db.query<User>(
"SELECT id FROM users WHERE email = $1",
[data.email]
);
if (existing.length > 0) {
throw new ConflictError(`L'email ${data.email} è già registrata`);
}
const hashedPassword = await hashPassword(data.password);
const rows = await this.db.query<User>(
`INSERT INTO users (name, email, password_hash)
VALUES ($1, $2, $3)
RETURNING id, name, email, role, created_at, updated_at`,
[data.name, data.email, hashedPassword]
);
return rows[0];
}
async update(id: string, data: UpdateUserInput): Promise<User> {
const user = await this.findById(id);
if (!user) {
throw new NotFoundError("Utente", id);
}
// Costruzione dinamica della query di aggiornamento
const fields: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
if (data.name !== undefined) {
fields.push(`name = $${paramIndex++}`);
values.push(data.name);
}
if (data.email !== undefined) {
fields.push(`email = $${paramIndex++}`);
values.push(data.email);
}
values.push(id);
const rows = await this.db.query<User>(
`UPDATE users SET ${fields.join(", ")}, updated_at = NOW()
WHERE id = $${paramIndex}
RETURNING *`,
values
);
return rows[0];
}
async delete(id: string): Promise<void> {
const user = await this.findById(id);
if (!user) {
throw new NotFoundError("Utente", id);
}
await this.db.query("DELETE FROM users WHERE id = $1", [id]);
}
async list(filters: UserFilters): Promise<PaginatedResult<User>> {
const offset = (filters.page - 1) * filters.limit;
const conditions: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
if (filters.search) {
conditions.push(`(name ILIKE $${paramIndex} OR email ILIKE $${paramIndex})`);
values.push(`%${filters.search}%`);
paramIndex++;
}
if (filters.role) {
conditions.push(`role = $${paramIndex}`);
values.push(filters.role);
paramIndex++;
}
const where = conditions.length > 0
? `WHERE ${conditions.join(" AND ")}`
: "";
// Conteggio totale
const countResult = await this.db.query<{ count: number }>(
`SELECT COUNT(*) as count FROM users ${where}`,
values
);
const total = countResult[0].count;
// Recupero dei dati paginati
const data = await this.db.query<User>(
`SELECT * FROM users ${where} ORDER BY created_at DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
[...values, filters.limit, offset]
);
return {
data,
total,
page: filters.page,
limit: filters.limit,
totalPages: Math.ceil(total / filters.limit),
};
}
}
Il servizio può essere reso disponibile tramite un plugin dedicato che lo registra come decoratore dell'istanza Fastify, oppure istanziato direttamente negli handler. Il primo approccio è preferibile perché mantiene la coerenza con l'architettura a plugin del framework.
Considerazioni sulla configurazione in produzione
Prima di portare un'applicazione Fastify in produzione, ci sono alcune configurazioni da tenere presenti. La gestione delle variabili d'ambiente merita una tipizzazione rigorosa per evitare errori a runtime.
import { Type, Static } from "@sinclair/typebox";
import { Value } from "@sinclair/typebox/value";
const EnvSchema = Type.Object({
NODE_ENV: Type.Union([
Type.Literal("development"),
Type.Literal("production"),
Type.Literal("test"),
]),
PORT: Type.Number({ default: 3000 }),
HOST: Type.String({ default: "0.0.0.0" }),
DATABASE_URL: Type.String(),
JWT_SECRET: Type.String({ minLength: 32 }),
CORS_ORIGIN: Type.String({ default: "*" }),
LOG_LEVEL: Type.Union([
Type.Literal("fatal"),
Type.Literal("error"),
Type.Literal("warn"),
Type.Literal("info"),
Type.Literal("debug"),
Type.Literal("trace"),
], { default: "info" }),
});
type Env = Static<typeof EnvSchema>;
export function loadEnv(): Env {
// Validazione con TypeBox
const raw = {
NODE_ENV: process.env.NODE_ENV,
PORT: Number(process.env.PORT) || undefined,
HOST: process.env.HOST,
DATABASE_URL: process.env.DATABASE_URL,
JWT_SECRET: process.env.JWT_SECRET,
CORS_ORIGIN: process.env.CORS_ORIGIN,
LOG_LEVEL: process.env.LOG_LEVEL,
};
// Lancia un errore se la validazione fallisce
const env = Value.Decode(EnvSchema, raw);
return env;
}
L'approccio di validare le variabili d'ambiente all'avvio garantisce che l'applicazione non si trovi in uno stato inconsistente dopo il deploy. Un errore chiaro al momento del boot è molto più facile da diagnosticare rispetto a un crash casuale dopo ore di funzionamento perché una variabile non era stata definita.
Conclusione
Fastify e TypeScript formano un binomio maturo e produttivo. Il framework non tratta i tipi come un'aggiunta secondaria: le sue interfacce generiche, il supporto nativo per la dichiarazione di modulo e l'integrazione con JSON Schema rendono la type safety un elemento strutturale del progetto, non un ornamento.
I punti chiave da ricordare sono: usare sempre strict: true nel tsconfig.json;
definire i tipi delle rotte con le interfacce generiche di Fastify; sfruttare TypeBox per unificare
schema e tipi; estendere le interfacce del framework con declare module quando si creano
plugin; separare la logica di business in un service layer tipizzato; e validare le variabili
d'ambiente all'avvio.
Il risultato è un'applicazione in cui il compilatore diventa un alleato attivo: cattura errori di integrazione tra i livelli, impedisce disallineamenti tra schema e codice, e documenta i contratti delle API in modo eseguibile. Questo si traduce in meno bug in produzione e in una base di codice che rimane navigabile anche quando cresce.