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.