ExpressJS con TypeScript

Express è il framework HTTP più diffuso nell'ecosistema Node.js. Nato nel 2010, deve la sua popolarità a un'architettura minimalista che lascia allo sviluppatore la libertà di comporre lo stack applicativo pezzo per pezzo. Tuttavia, la sua natura JavaScript pura introduce fragilità che emergono con la crescita del progetto: parametri con tipi ambigui, oggetti request privi di forma definita, middleware che si affidano a convenzioni implicite.

TypeScript risolve queste fragilità. Aggiungendo un sistema di tipi statico sopra JavaScript, trasforma errori a runtime in errori a compile-time, rende espliciti i contratti fra le parti del codice e apre la strada a un refactoring sicuro. Questa guida costruisce un'applicazione Express in TypeScript partendo dalla configurazione iniziale fino ad arrivare a pattern avanzati come middleware tipizzati, validazione con Zod, gestione centralizzata degli errori e test automatici.

Prerequisiti

Per seguire l'articolo è necessario avere installato Node.js versione 18 o superiore e un package manager fra npm, yarn o pnpm. Si assume familiarità con i concetti fondamentali di Express (routing, middleware, request/response) e con la sintassi base di TypeScript (interfacce, tipi generici, type narrowing).

Inizializzazione del progetto

Creiamo la cartella del progetto e inizializziamo il file package.json:

# Crea la cartella e inizializza il progetto
mkdir express-ts-app
cd express-ts-app
npm init -y

Installiamo le dipendenze di produzione e di sviluppo. Express è l'unica dipendenza di runtime; tutto il resto serve solo in fase di build o di sviluppo:

# Dipendenze di produzione
npm install express

# Dipendenze di sviluppo
npm install -D typescript @types/node @types/express tsx

Il pacchetto @types/express contiene le definizioni di tipo ufficiali per Express: descrive la forma di Request, Response, NextFunction, Router e di tutti gli altri costrutti del framework. Il pacchetto tsx ci permette di eseguire file TypeScript direttamente senza un passaggio di compilazione manuale, ideale per lo sviluppo locale.

Configurazione di TypeScript

Generiamo il file tsconfig.json e personalizziamolo per un progetto Express:

# Genera il file tsconfig.json di base
npx tsc --init

Modifichiamo il file generato con le impostazioni appropriate:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Le opzioni meritano una spiegazione. target: "ES2022" consente l'uso di funzionalità moderne come top-level await e metodi di array recenti, dato che il runtime è Node.js e non un browser legacy. module: "Node16" e moduleResolution: "Node16" attivano il supporto al sistema di moduli nativo di Node, che rispetta il campo "type" nel package.json per decidere se un file è ESM o CommonJS. strict: true abilita l'intero pacchetto di controlli rigorosi, fra cui strictNullChecks, noImplicitAny e strictFunctionTypes, ed è imprescindibile per ottenere il massimo beneficio dal type system.

Struttura del progetto

Una struttura ordinata facilita la navigazione del codice e la separazione delle responsabilità. Organizziamo il progetto così:

express-ts-app/
  src/
    config/
      env.ts
    middlewares/
      errorHandler.ts
      validate.ts
      requestLogger.ts
    routes/
      userRoutes.ts
      healthRoutes.ts
    controllers/
      userController.ts
    services/
      userService.ts
    types/
      index.ts
    app.ts
    server.ts
  tsconfig.json
  package.json

La cartella config contiene tutto ciò che riguarda la configurazione dell'ambiente. middlewares raccoglie i middleware riutilizzabili. routes definisce gli endpoint raggruppati per dominio. controllers gestisce il ciclo request-response. services incapsula la logica di business. types ospita le definizioni di tipo condivise.

Configurazione dell'ambiente

Iniziamo dal file di configurazione. Anziché leggere process.env ovunque nel codice, centralizziamo la lettura in un unico punto con validazione integrata:

// src/config/env.ts

// Interfaccia che descrive le variabili d'ambiente attese
interface EnvConfig {
  port: number;
  nodeEnv: string;
  apiPrefix: string;
}

// Funzione che legge e valida le variabili d'ambiente
function loadEnvConfig(): EnvConfig {
  const port = parseInt(process.env.PORT ?? "3000", 10);

  if (isNaN(port)) {
    throw new Error("La variabile PORT deve essere un numero valido");
  }

  return {
    port,
    nodeEnv: process.env.NODE_ENV ?? "development",
    apiPrefix: process.env.API_PREFIX ?? "/api",
  };
}

export const envConfig = loadEnvConfig();

Grazie a questa centralizzazione, ogni modulo che importa envConfig riceve un oggetto con tipi certi: port è un number, non una string | undefined come sarebbe con process.env.PORT diretto.

Definizione dei tipi

Prima di scrivere qualsiasi logica, definiamo i tipi del dominio applicativo. Questo approccio, detto type-first design, costringe a pensare alla forma dei dati prima di scrivere il codice che li manipola:

// src/types/index.ts

// Rappresenta un utente nel sistema
export interface User {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
  updatedAt: Date;
}

// Payload per la creazione di un utente (senza campi auto-generati)
export interface CreateUserPayload {
  name: string;
  email: string;
}

// Payload per l'aggiornamento parziale di un utente
export interface UpdateUserPayload {
  name?: string;
  email?: string;
}

// Struttura standard per le risposte API con successo
export interface ApiResponse<T> {
  success: true;
  data: T;
  message?: string;
}

// Struttura standard per le risposte API con errore
export interface ApiErrorResponse {
  success: false;
  error: string;
  details?: unknown;
}

// Parametri di paginazione per le query a lista
export interface PaginationParams {
  page: number;
  limit: number;
}

// Risposta paginata generica
export interface PaginatedResponse<T> {
  items: T[];
  total: number;
  page: number;
  limit: number;
  totalPages: number;
}

Il tipo generico ApiResponse<T> è particolarmente utile: permette di tipizzare la risposta in modo preciso a seconda dell'endpoint. Un endpoint che restituisce un singolo utente userà ApiResponse<User>, mentre una lista userà ApiResponse<PaginatedResponse<User>>.

Il service layer

Il service layer incapsula la logica di business e l'accesso ai dati. In un progetto reale qui si troverebbero le chiamate al database; per semplicità usiamo un array in memoria:

// src/services/userService.ts

import { User, CreateUserPayload, UpdateUserPayload, PaginatedResponse } from "../types/index.js";
import crypto from "node:crypto";

// Simulazione di un database in memoria
const users: User[] = [];

// Genera un ID univoco per ogni nuovo utente
function generateId(): string {
  return crypto.randomUUID();
}

// Restituisce una lista paginata di utenti
export function findAllUsers(page: number, limit: number): PaginatedResponse<User> {
  const start = (page - 1) * limit;
  const end = start + limit;
  const paginatedItems = users.slice(start, end);

  return {
    items: paginatedItems,
    total: users.length,
    page,
    limit,
    totalPages: Math.ceil(users.length / limit),
  };
}

// Cerca un utente per ID, restituisce undefined se non trovato
export function findUserById(id: string): User | undefined {
  return users.find((user) => user.id === id);
}

// Crea un nuovo utente e lo aggiunge al database
export function createUser(payload: CreateUserPayload): User {
  const now = new Date();

  const newUser: User = {
    id: generateId(),
    name: payload.name,
    email: payload.email,
    createdAt: now,
    updatedAt: now,
  };

  users.push(newUser);
  return newUser;
}

// Aggiorna un utente esistente, restituisce null se non trovato
export function updateUser(id: string, payload: UpdateUserPayload): User | null {
  const index = users.findIndex((user) => user.id === id);

  if (index === -1) {
    return null;
  }

  const existingUser = users[index];

  // Merge dei campi aggiornati con quelli esistenti
  const updatedUser: User = {
    ...existingUser,
    ...payload,
    updatedAt: new Date(),
  };

  users[index] = updatedUser;
  return updatedUser;
}

// Elimina un utente per ID, restituisce true se eliminato
export function deleteUser(id: string): boolean {
  const index = users.findIndex((user) => user.id === id);

  if (index === -1) {
    return false;
  }

  users.splice(index, 1);
  return true;
}

Ogni funzione del service ha un tipo di ritorno esplicito. Il tipo User | undefined di findUserById obbliga chi chiama la funzione a gestire il caso in cui l'utente non esista, rendendo impossibile dimenticarsene.

Gestione centralizzata degli errori

Express gestisce gli errori attraverso un middleware speciale a quattro parametri. Con TypeScript possiamo creare una classe di errore personalizzata che trasporta il codice HTTP e poi un middleware che la intercetta:

// src/middlewares/errorHandler.ts

import { Request, Response, NextFunction } from "express";
import { ApiErrorResponse } from "../types/index.js";

// Classe di errore personalizzata che include il codice di stato HTTP
export class HttpError extends Error {
  public readonly statusCode: number;
  public readonly details?: unknown;

  constructor(statusCode: number, message: string, details?: unknown) {
    super(message);
    this.statusCode = statusCode;
    this.details = details;

    // Necessario per far funzionare instanceof con classi che estendono Error
    Object.setPrototypeOf(this, HttpError.prototype);
  }
}

// Errori specifici per casi comuni
export class NotFoundError extends HttpError {
  constructor(resource: string) {
    super(404, `${resource} non trovato`);
  }
}

export class ValidationError extends HttpError {
  constructor(details: unknown) {
    super(400, "Errore di validazione", details);
  }
}

// Middleware di gestione errori (deve avere esattamente 4 parametri)
export function errorHandler(
  err: Error,
  _req: Request,
  res: Response<ApiErrorResponse>,
  _next: NextFunction
): void {
  // Logga l'errore per il debugging in ambiente di sviluppo
  console.error(`[Errore] ${err.message}`, err.stack);

  if (err instanceof HttpError) {
    res.status(err.statusCode).json({
      success: false,
      error: err.message,
      details: err.details,
    });
    return;
  }

  // Errore generico non previsto: restituisci 500
  res.status(500).json({
    success: false,
    error: "Errore interno del server",
  });
}

La sottoclasse NotFoundError è un esempio di come semplificare il codice nei controller: anziché ripetere res.status(404).json({...}) in ogni endpoint, si lancia un new NotFoundError("Utente") e il middleware centralizzato fa il resto.

Middleware di logging

Un middleware di logging è utile per monitorare le richieste in arrivo. TypeScript ci garantisce che accediamo solo a proprietà realmente esistenti sull'oggetto Request:

// src/middlewares/requestLogger.ts

import { Request, Response, NextFunction } from "express";

// Middleware che logga metodo, URL e tempo di risposta per ogni richiesta
export function requestLogger(req: Request, res: Response, next: NextFunction): void {
  const startTime = Date.now();

  // Intercetta l'evento di fine risposta per calcolare la durata
  res.on("finish", () => {
    const duration = Date.now() - startTime;
    const logLine = `${req.method} ${req.originalUrl} ${res.statusCode} - ${duration}ms`;

    console.log(`[${new Date().toISOString()}] ${logLine}`);
  });

  next();
}

Validazione con Zod

La validazione dell'input è uno dei punti dove TypeScript da solo non basta. I tipi vengono cancellati a runtime, quindi non possono proteggere da un JSON malformato in arrivo dal client. Zod colma questa lacuna: definisce schemi che validano i dati a runtime e ne inferisce automaticamente il tipo TypeScript.

# Installa Zod come dipendenza di produzione
npm install zod

Creiamo un middleware generico di validazione:

// src/middlewares/validate.ts

import { Request, Response, NextFunction } from "express";
import { ZodSchema, ZodError } from "zod";
import { ValidationError } from "./errorHandler.js";

// Indica quale parte della richiesta validare
type RequestProperty = "body" | "query" | "params";

// Middleware factory che valida una porzione della richiesta con uno schema Zod
export function validate(schema: ZodSchema, property: RequestProperty = "body") {
  return (req: Request, _res: Response, next: NextFunction): void => {
    try {
      // Il risultato del parse sovrascrive la proprietà originale con dati tipizzati e puliti
      const parsed = schema.parse(req[property]);
      req[property] = parsed;
      next();
    } catch (err) {
      if (err instanceof ZodError) {
        // Trasforma gli errori Zod in un formato leggibile per il client
        const details = err.errors.map((e) => ({
          field: e.path.join("."),
          message: e.message,
        }));

        next(new ValidationError(details));
        return;
      }

      next(err);
    }
  };
}

Questo middleware factory prende uno schema Zod e restituisce un middleware Express. Se la validazione fallisce, produce automaticamente una risposta di errore strutturata con i dettagli campo per campo.

Controller

Il controller riceve la richiesta HTTP, invoca il service appropriato e restituisce la risposta. Notiamo come i tipi generici di Request di Express permettano di specificare la forma dei parametri, del body e della query:

// src/controllers/userController.ts

import { Request, Response, NextFunction } from "express";
import {
  findAllUsers,
  findUserById,
  createUser,
  updateUser,
  deleteUser,
} from "../services/userService.js";
import { CreateUserPayload, UpdateUserPayload, ApiResponse, User, PaginatedResponse } from "../types/index.js";
import { NotFoundError } from "../middlewares/errorHandler.js";

// GET /users - Restituisce la lista paginata degli utenti
export function getAllUsers(
  req: Request,
  res: Response<ApiResponse<PaginatedResponse<User>>>,
  _next: NextFunction
): void {
  const page = parseInt(req.query.page as string) || 1;
  const limit = parseInt(req.query.limit as string) || 10;

  const result = findAllUsers(page, limit);

  res.json({
    success: true,
    data: result,
  });
}

// GET /users/:id - Restituisce un singolo utente per ID
export function getUserById(
  req: Request<{ id: string }>,
  res: Response<ApiResponse<User>>,
  next: NextFunction
): void {
  const user = findUserById(req.params.id);

  if (!user) {
    next(new NotFoundError("Utente"));
    return;
  }

  res.json({
    success: true,
    data: user,
  });
}

// POST /users - Crea un nuovo utente
export function createNewUser(
  req: Request<unknown, unknown, CreateUserPayload>,
  res: Response<ApiResponse<User>>,
  _next: NextFunction
): void {
  const user = createUser(req.body);

  res.status(201).json({
    success: true,
    data: user,
    message: "Utente creato con successo",
  });
}

// PATCH /users/:id - Aggiorna parzialmente un utente
export function updateExistingUser(
  req: Request<{ id: string }, unknown, UpdateUserPayload>,
  res: Response<ApiResponse<User>>,
  next: NextFunction
): void {
  const user = updateUser(req.params.id, req.body);

  if (!user) {
    next(new NotFoundError("Utente"));
    return;
  }

  res.json({
    success: true,
    data: user,
    message: "Utente aggiornato con successo",
  });
}

// DELETE /users/:id - Elimina un utente per ID
export function deleteExistingUser(
  req: Request<{ id: string }>,
  res: Response<ApiResponse<{ deleted: boolean }>>,
  next: NextFunction
): void {
  const deleted = deleteUser(req.params.id);

  if (!deleted) {
    next(new NotFoundError("Utente"));
    return;
  }

  res.json({
    success: true,
    data: { deleted: true },
    message: "Utente eliminato con successo",
  });
}

Il tipo Request<Params, ResBody, ReqBody, Query> è un generico con quattro parametri posizionali. Specificando Request<{ id: string }> rendiamo req.params.id una stringa certa, senza bisogno di cast.

Definizione delle rotte

Le rotte collegano gli URL ai controller e interpongono i middleware di validazione. Usiamo il Router di Express per organizzare le rotte in moduli:

// src/routes/userRoutes.ts

import { Router } from "express";
import { z } from "zod";
import {
  getAllUsers,
  getUserById,
  createNewUser,
  updateExistingUser,
  deleteExistingUser,
} from "../controllers/userController.js";
import { validate } from "../middlewares/validate.js";

// Schema Zod per la validazione del body di creazione utente
const createUserSchema = z.object({
  name: z
    .string()
    .min(2, "Il nome deve avere almeno 2 caratteri")
    .max(100, "Il nome non può superare i 100 caratteri"),
  email: z
    .string()
    .email("Indirizzo email non valido"),
});

// Schema Zod per la validazione del body di aggiornamento utente
const updateUserSchema = z.object({
  name: z
    .string()
    .min(2, "Il nome deve avere almeno 2 caratteri")
    .max(100, "Il nome non può superare i 100 caratteri")
    .optional(),
  email: z
    .string()
    .email("Indirizzo email non valido")
    .optional(),
});

const router = Router();

// Rotte CRUD per la risorsa "utenti"
router.get("/", getAllUsers);
router.get("/:id", getUserById);
router.post("/", validate(createUserSchema), createNewUser);
router.patch("/:id", validate(updateUserSchema), updateExistingUser);
router.delete("/:id", deleteExistingUser);

export default router;

Creiamo anche un endpoint di health check, utile per i load balancer e i sistemi di orchestrazione:

// src/routes/healthRoutes.ts

import { Router, Request, Response } from "express";

const router = Router();

// Endpoint di health check per monitoraggio e readiness probe
router.get("/", (_req: Request, res: Response) => {
  res.json({
    status: "ok",
    timestamp: new Date().toISOString(),
    uptime: process.uptime(),
  });
});

export default router;

Composizione dell'applicazione

Il file app.ts assembla tutti i pezzi: middleware globali, rotte e gestore degli errori. Separiamo la creazione dell'app dall'avvio del server per facilitare il testing:

// src/app.ts

import express from "express";
import { envConfig } from "./config/env.js";
import { requestLogger } from "./middlewares/requestLogger.js";
import { errorHandler } from "./middlewares/errorHandler.js";
import userRoutes from "./routes/userRoutes.js";
import healthRoutes from "./routes/healthRoutes.js";

// Crea e configura l'applicazione Express
export function createApp(): express.Application {
  const app = express();

  // Middleware globali: parsing JSON e logging
  app.use(express.json());
  app.use(requestLogger);

  // Registrazione delle rotte con prefisso API configurabile
  app.use(`${envConfig.apiPrefix}/users`, userRoutes);
  app.use(`${envConfig.apiPrefix}/health`, healthRoutes);

  // Il middleware di errore deve essere registrato per ultimo
  app.use(errorHandler);

  return app;
}
// src/server.ts

import { createApp } from "./app.js";
import { envConfig } from "./config/env.js";

const app = createApp();

// Avvia il server sulla porta configurata
app.listen(envConfig.port, () => {
  console.log(
    `Server in ascolto sulla porta ${envConfig.port} in modalità ${envConfig.nodeEnv}`
  );
});

Script di avvio

Aggiungiamo gli script necessari al package.json:

{
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js",
    "typecheck": "tsc --noEmit"
  }
}

Lo script dev usa tsx watch per riavviare il server ad ogni modifica. build compila TypeScript in JavaScript nella cartella dist. start avvia il codice compilato in produzione. typecheck verifica i tipi senza produrre output, utile nelle pipeline CI.

Avviamo il server in modalità sviluppo:

# Avvia il server con hot-reload
npm run dev

Testiamo gli endpoint con curl:

# Verifica lo stato del server
curl http://localhost:3000/api/health

# Crea un nuovo utente
curl -X POST http://localhost:3000/api/users \
  -H "Content-Type: application/json" \
  -d '{"name": "Mario Rossi", "email": "mario@example.com"}'

# Recupera la lista utenti
curl http://localhost:3000/api/users

# Testa la validazione con dati non validi
curl -X POST http://localhost:3000/api/users \
  -H "Content-Type: application/json" \
  -d '{"name": "M", "email": "non-valida"}'

Estendere il tipo Request

Un pattern molto comune nelle applicazioni Express è quello di arricchire l'oggetto Request con informazioni aggiuntive attraverso i middleware, ad esempio i dati dell'utente autenticato. TypeScript offre un meccanismo chiamato declaration merging per estendere interfacce esistenti in modo sicuro:

// src/types/express.d.ts

// Estende il namespace Express per aggiungere proprietà personalizzate a Request
declare namespace Express {
  interface Request {
    userId?: string;
    role?: "admin" | "editor" | "viewer";
  }
}

Con questa dichiarazione, req.userId e req.role diventano proprietà riconosciute dal compilatore in tutti i file del progetto. Il punto interrogativo li rende opzionali, riflettendo il fatto che saranno valorizzati solo dopo il passaggio del middleware di autenticazione.

Ecco un esempio di middleware di autenticazione che sfrutta questa estensione:

// src/middlewares/authenticate.ts

import { Request, Response, NextFunction } from "express";
import { HttpError } from "./errorHandler.js";

// Middleware che verifica il token e arricchisce la request con i dati dell'utente
export function authenticate(req: Request, _res: Response, next: NextFunction): void {
  const authHeader = req.headers.authorization;

  if (!authHeader?.startsWith("Bearer ")) {
    next(new HttpError(401, "Token di autenticazione mancante"));
    return;
  }

  const token = authHeader.slice(7);

  // In un progetto reale qui si verificherebbe il JWT
  // Per semplicità accettiamo qualsiasi token non vuoto
  if (token.length === 0) {
    next(new HttpError(401, "Token non valido"));
    return;
  }

  // Arricchisci la request con i dati estratti dal token
  req.userId = "user-123";
  req.role = "editor";

  next();
}

Gestione asincrona degli errori

Express 4 non cattura automaticamente le eccezioni lanciate da funzioni asincrone. Se un controller async lancia un errore, il server va in crash anziché invocare il middleware di errore. Esistono due soluzioni.

La prima è un wrapper manuale che cattura le promise rifiutate e le passa a next:

// src/middlewares/asyncHandler.ts

import { Request, Response, NextFunction } from "express";

// Tipo che rappresenta un handler Express asincrono
type AsyncHandler = (req: Request, res: Response, next: NextFunction) => Promise<void>;

// Wrapper che cattura errori da handler asincroni e li passa al middleware di errore
export function asyncHandler(handler: AsyncHandler) {
  return (req: Request, res: Response, next: NextFunction): void => {
    handler(req, res, next).catch(next);
  };
}

Si usa così nei route:

// Esempio di utilizzo con asyncHandler
router.get("/data", asyncHandler(async (req, res) => {
  // Se questa promise viene rifiutata, l'errore arriva al middleware di errore
  const data = await fetchExternalData();
  res.json({ success: true, data });
}));

La seconda soluzione è passare a Express 5, che gestisce nativamente le promise rifiutate nei route handler. Al momento della scrittura di questo articolo, Express 5 è disponibile e utilizzabile in produzione:

# Installa Express 5 (gestione nativa degli errori async)
npm install express@5

Pattern avanzato: middleware tipizzato con generici

Un pattern potente consiste nel creare middleware fortemente tipizzati che trasformano la request e propagano il tipo trasformato ai handler successivi. Questo si ottiene attraverso una funzione factory generica:

// src/middlewares/parseQuery.ts

import { Request, Response, NextFunction } from "express";

// Tipo per una funzione di parsing generica
type QueryParser<T> = (raw: Record<string, string | undefined>) => T;

// Middleware factory che parsa la query string e la attacca alla request
export function parseQuery<T>(parser: QueryParser<T>) {
  return (req: Request, _res: Response, next: NextFunction): void => {
    try {
      // Converte i parametri della query string secondo la funzione di parsing fornita
      const parsed = parser(req.query as Record<string, string | undefined>);
      (req as Request & { parsedQuery: T }).parsedQuery = parsed;
      next();
    } catch (err) {
      next(err);
    }
  };
}

Test dell'applicazione

La separazione fra app.ts e server.ts rende semplice il testing: possiamo importare createApp e testarla con supertest senza avviare un vero server HTTP.

# Installa le dipendenze per il testing
npm install -D vitest supertest @types/supertest

Configuriamo Vitest nel package.json:

{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest"
  }
}

Scriviamo i test per gli endpoint utenti:

// src/__tests__/users.test.ts

import { describe, it, expect, beforeEach } from "vitest";
import request from "supertest";
import { createApp } from "../app.js";

// Crea una nuova istanza dell'app per ogni gruppo di test
const app = createApp();

describe("API Utenti", () => {
  let createdUserId: string;

  it("restituisce una lista vuota inizialmente", async () => {
    const response = await request(app)
      .get("/api/users")
      .expect(200);

    expect(response.body.success).toBe(true);
    expect(response.body.data.items).toHaveLength(0);
  });

  it("crea un nuovo utente con dati validi", async () => {
    const response = await request(app)
      .post("/api/users")
      .send({ name: "Test User", email: "test@example.com" })
      .expect(201);

    expect(response.body.success).toBe(true);
    expect(response.body.data.name).toBe("Test User");
    expect(response.body.data.email).toBe("test@example.com");
    expect(response.body.data.id).toBeDefined();

    // Salva l'ID per i test successivi
    createdUserId = response.body.data.id;
  });

  it("rifiuta la creazione con email non valida", async () => {
    const response = await request(app)
      .post("/api/users")
      .send({ name: "Test", email: "non-valida" })
      .expect(400);

    expect(response.body.success).toBe(false);
    expect(response.body.details).toBeDefined();
  });

  it("rifiuta la creazione con nome troppo corto", async () => {
    const response = await request(app)
      .post("/api/users")
      .send({ name: "A", email: "ok@example.com" })
      .expect(400);

    expect(response.body.success).toBe(false);
  });

  it("recupera un utente per ID", async () => {
    const response = await request(app)
      .get(`/api/users/${createdUserId}`)
      .expect(200);

    expect(response.body.data.id).toBe(createdUserId);
  });

  it("restituisce 404 per un ID inesistente", async () => {
    await request(app)
      .get("/api/users/id-inesistente")
      .expect(404);
  });

  it("aggiorna il nome di un utente", async () => {
    const response = await request(app)
      .patch(`/api/users/${createdUserId}`)
      .send({ name: "Nome Aggiornato" })
      .expect(200);

    expect(response.body.data.name).toBe("Nome Aggiornato");
  });

  it("elimina un utente esistente", async () => {
    await request(app)
      .delete(`/api/users/${createdUserId}`)
      .expect(200);

    // Verifica che l'utente non esista più
    await request(app)
      .get(`/api/users/${createdUserId}`)
      .expect(404);
  });
});

Build e deployment

Per il deployment in produzione, compiliamo il progetto e avviamolo con Node.js direttamente:

# Compila il progetto
npm run build

# Verifica il contenuto della cartella dist
ls dist/

# Avvia in produzione
NODE_ENV=production PORT=8080 node dist/server.js

Per un deployment containerizzato, ecco un Dockerfile multi-stage che produce un'immagine leggera:

# Fase di build: compila TypeScript in JavaScript
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build

# Fase di produzione: immagine minimale con solo il codice compilato
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist

# Utente non-root per sicurezza
USER node

EXPOSE 3000
CMD ["node", "dist/server.js"]

La fase builder installa tutte le dipendenze (incluse quelle di sviluppo) e compila il progetto. La fase di produzione parte da un'immagine pulita, installa solo le dipendenze di runtime e copia il codice compilato dalla fase precedente. Il risultato è un'immagine Docker significativamente più piccola.

Considerazioni finali

Integrare TypeScript in un progetto Express non è un esercizio cosmetico. Cambia il modo in cui si progetta il codice: le interfacce diventano contratti vivi, i middleware dichiarano esplicitamente cosa aggiungono alla request, i controller rendono evidenti le forme di input e output, e il compilatore segnala le incongruenze prima ancora di avviare il server.

I benefici crescono in proporzione alla dimensione del progetto. In un microservizio con tre endpoint la differenza è marginale. In un'applicazione con decine di rotte, middleware condivisi, validazione complessa e molteplici sviluppatori che lavorano in parallelo, il type system diventa una rete di sicurezza indispensabile che riduce i bug, accelera il refactoring e funge da documentazione sempre aggiornata.

I prossimi passi naturali includono l'integrazione con un ORM tipizzato come Prisma o Drizzle, l'aggiunta di autenticazione JWT con librerie come jose, la documentazione automatica delle API con OpenAPI attraverso strumenti come tsoa o zod-to-openapi, e la configurazione di un pipeline CI/CD che esegua type-checking e test ad ogni push.