Node.js e TypeScript
TypeScript ha cambiato radicalmente il modo in cui si scrive codice JavaScript lato server. Quello che una volta era un linguaggio dinamico, flessibile ma incline a errori subdoli in fase di runtime, oggi può contare su un sistema di tipi statico che intercetta intere categorie di bug prima ancora che il programma venga eseguito. Node.js, dal canto suo, è diventato la piattaforma di riferimento per backend ad alte prestazioni, microservizi, tool da riga di comando e molto altro. L'unione di queste due tecnologie rappresenta lo stato dell'arte per lo sviluppo server-side nel mondo JavaScript.
Questo articolo esplora in profondità come configurare, strutturare e scrivere applicazioni Node.js in TypeScript, coprendo la configurazione del compilatore, i pattern architetturali più solidi, la gestione degli errori, il testing e le best practice che ogni sviluppatore dovrebbe conoscere.
Perché TypeScript su Node.js
JavaScript è un linguaggio a tipizzazione dinamica: le variabili non hanno un tipo fisso, le funzioni accettano qualunque cosa e gli errori legati ai tipi emergono solo a runtime, spesso in produzione. TypeScript risolve questo problema introducendo un layer di tipizzazione statica che viene verificato in fase di compilazione e poi rimosso completamente, producendo JavaScript puro compatibile con qualsiasi runtime Node.js.
I vantaggi concreti sono molteplici. Il refactoring diventa sicuro: rinominare una proprietà o modificare la firma di una funzione produce immediatamente errori di compilazione in tutti i punti del codice che devono essere aggiornati. L'autocompletamento nell'editor diventa preciso e contestuale, perché il language server conosce esattamente il tipo di ogni espressione. La documentazione diventa implicita nel codice stesso, perché le interfacce e i tipi descrivono in modo formale la struttura dei dati. E infine, intere classi di bug — accessi a proprietà inesistenti, argomenti passati nell'ordine sbagliato, valori null non gestiti — vengono eliminate prima ancora di eseguire il programma.
Inizializzazione del progetto
La configurazione iniziale di un progetto Node.js con TypeScript richiede pochi passaggi ma è importante farli correttamente fin dall'inizio. Si parte dalla creazione della cartella del progetto e dall'inizializzazione di npm e TypeScript.
# Creazione della cartella e inizializzazione
mkdir my-app
cd my-app
npm init -y
# Installazione di TypeScript e dei tipi per Node.js
npm install --save-dev typescript @types/node
# Generazione del file di configurazione TypeScript
npx tsc --init
Il comando npx tsc --init genera un file tsconfig.json con tutte le opzioni commentate. Partire da quel file e personalizzarlo è la prassi consigliata, ma conviene conoscere nel dettaglio le opzioni più importanti.
Configurazione del compilatore
Il file tsconfig.json è il cuore della configurazione TypeScript. Ogni opzione influenza il comportamento del compilatore, il livello di rigore dei controlli e la compatibilità del codice prodotto. Ecco una configurazione robusta per un progetto Node.js moderno.
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
Analizziamo le opzioni chiave. target definisce la versione di JavaScript prodotta dal compilatore: ES2022 è una scelta sicura per le versioni moderne di Node.js (18+) e garantisce accesso a feature come Array.at(), top-level await e Object.hasOwn(). L'opzione module impostata su Node16 abilita il supporto nativo per ESM e CommonJS, rispettando le regole di risoluzione dei moduli di Node.js. strict attiva in blocco tutte le opzioni di controllo rigoroso: strictNullChecks, strictFunctionTypes, strictBindCallApply, noImplicitAny, noImplicitThis e strictPropertyInitialization. Disattivare strict è un errore che si paga nel tempo.
L'opzione exactOptionalPropertyTypes merita attenzione speciale. Quando attiva, distingue tra una proprietà opzionale (che può essere assente) e una proprietà che può valere undefined. Questa distinzione è fondamentale per lavorare correttamente con API che usano hasOwnProperty o l'operatore in.
Struttura del progetto
Una struttura ben organizzata facilita la manutenzione e la scalabilità. Il pattern più diffuso e collaudato per applicazioni Node.js con TypeScript prevede una separazione netta tra codice sorgente, configurazione e output compilato.
my-app/
├── src/
│ ├── index.ts
│ ├── config/
│ │ └── environment.ts
│ ├── routes/
│ │ └── userRoutes.ts
│ ├── controllers/
│ │ └── userController.ts
│ ├── services/
│ │ └── userService.ts
│ ├── repositories/
│ │ └── userRepository.ts
│ ├── models/
│ │ └── user.ts
│ ├── middleware/
│ │ └── authMiddleware.ts
│ ├── utils/
│ │ └── logger.ts
│ └── types/
│ └── index.ts
├── tests/
│ ├── unit/
│ └── integration/
├── dist/
├── tsconfig.json
├── package.json
└── .env
La cartella src/types/ contiene le definizioni di tipo condivise nell'intera applicazione. Non si tratta di file .d.ts di dichiarazione, ma di normali file .ts che esportano interfacce, type alias e enum usati trasversalmente dai vari moduli.
Il sistema di tipi applicato a Node.js
TypeScript offre un sistema di tipi estremamente espressivo. Comprendere le sue feature più avanzate è essenziale per scrivere codice Node.js robusto e manutenibile. Partiamo dalle basi e saliamo progressivamente di complessità.
Interfacce e type alias
Le interfacce descrivono la forma degli oggetti. Sono estendibili, componibili e rappresentano il modo idiomatico per definire contratti nel codice TypeScript.
// Interfaccia base per un'entità del database
interface BaseEntity {
id: string;
createdAt: Date;
updatedAt: Date;
}
// Estensione dell'interfaccia per il modello utente
interface User extends BaseEntity {
email: string;
username: string;
passwordHash: string;
role: UserRole;
profile: UserProfile;
}
// Tipo enumerato per i ruoli
enum UserRole {
Admin = "admin",
Editor = "editor",
Viewer = "viewer",
}
// Interfaccia annidata per il profilo
interface UserProfile {
firstName: string;
lastName: string;
avatarUrl: string | null;
bio?: string;
}
// Type alias per i dati in ingresso (creazione utente)
type CreateUserInput = Omit<User, "id" | "createdAt" | "updatedAt" | "passwordHash"> & {
password: string;
};
// Type alias per i dati in uscita (senza campi sensibili)
type UserResponse = Omit<User, "passwordHash">;
I utility types come Omit, Pick, Partial e Required sono strumenti fondamentali. Permettono di derivare nuovi tipi da quelli esistenti senza duplicare definizioni, mantenendo un'unica fonte di verità.
Generics
I generics sono il meccanismo che rende il sistema di tipi di TypeScript davvero potente. Permettono di scrivere codice riutilizzabile mantenendo la piena sicurezza dei tipi.
// Risposta paginata generica
interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
// Risultato di un'operazione che può fallire
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
// Funzione generica per il wrapping degli errori
function trySafe<T>(fn: () => T): Result<T> {
try {
const data = fn();
return { success: true, data };
} catch (thrown: unknown) {
// Normalizzazione dell'errore
const error = thrown instanceof Error
? thrown
: new Error(String(thrown));
return { success: false, error };
}
}
// Versione asincrona dello stesso pattern
async function trySafeAsync<T>(fn: () => Promise<T>): Promise<Result<T>> {
try {
const data = await fn();
return { success: true, data };
} catch (thrown: unknown) {
const error = thrown instanceof Error
? thrown
: new Error(String(thrown));
return { success: false, error };
}
}
Il pattern Result<T, E> è particolarmente utile in Node.js perché rende esplicita la possibilità di fallimento di un'operazione a livello di tipo. Chi chiama la funzione è obbligato dal compilatore a gestire entrambi i casi.
Discriminated unions
Le discriminated unions (o tagged unions) sono uno dei pattern più potenti di TypeScript. Permettono di modellare stati mutualmente esclusivi in modo che il compilatore possa verificare che ogni caso sia gestito correttamente.
// Eventi di un sistema di pagamento
type PaymentEvent =
| { type: "payment_initiated"; orderId: string; amount: number; currency: string }
| { type: "payment_authorized"; orderId: string; transactionId: string }
| { type: "payment_captured"; orderId: string; transactionId: string; capturedAt: Date }
| { type: "payment_failed"; orderId: string; reason: string; retryable: boolean }
| { type: "payment_refunded"; orderId: string; refundId: string; amount: number };
// Gestione esaustiva degli eventi
function handlePaymentEvent(event: PaymentEvent): void {
switch (event.type) {
case "payment_initiated":
// Accesso sicuro: il compilatore sa che 'amount' e 'currency' esistono
console.log(`Pagamento di ${event.amount} ${event.currency} avviato`);
break;
case "payment_authorized":
console.log(`Transazione ${event.transactionId} autorizzata`);
break;
case "payment_captured":
console.log(`Pagamento catturato il ${event.capturedAt.toISOString()}`);
break;
case "payment_failed":
if (event.retryable) {
console.log(`Pagamento fallito, nuovo tentativo possibile: ${event.reason}`);
}
break;
case "payment_refunded":
console.log(`Rimborso di ${event.amount} emesso con ID ${event.refundId}`);
break;
default:
// Verifica di esaustività a tempo di compilazione
const exhaustiveCheck: never = event;
throw new Error(`Evento non gestito: ${JSON.stringify(exhaustiveCheck)}`);
}
}
L'assegnazione a never nel ramo default è una tecnica fondamentale. Se si aggiunge un nuovo tipo di evento alla union senza aggiungere il relativo case, il compilatore segnalerà un errore perché il nuovo tipo non sarà assegnabile a never.
Gestione della configurazione
Ogni applicazione Node.js ha bisogno di leggere variabili d'ambiente per configurare connessioni al database, chiavi API, porte di ascolto e altri parametri. TypeScript permette di validare e tipizzare questa configurazione all'avvio, evitando errori a runtime causati da variabili mancanti o malformate.
// Definizione dello schema di configurazione
interface AppConfig {
port: number;
nodeEnv: "development" | "production" | "test";
database: {
host: string;
port: number;
name: string;
user: string;
password: string;
poolSize: number;
};
jwt: {
secret: string;
expiresIn: string;
};
cors: {
origins: string[];
};
}
// Funzione di parsing con validazione rigorosa
function loadConfig(): AppConfig {
// Funzione helper per leggere variabili obbligatorie
function requireEnv(key: string): string {
const value = process.env[key];
if (value === undefined || value === "") {
throw new Error(`Variabile d'ambiente obbligatoria mancante: ${key}`);
}
return value;
}
// Funzione helper per leggere variabili numeriche
function requireEnvAsInt(key: string, fallback?: number): number {
const raw = process.env[key];
if (raw === undefined || raw === "") {
if (fallback !== undefined) return fallback;
throw new Error(`Variabile d'ambiente numerica mancante: ${key}`);
}
const parsed = parseInt(raw, 10);
if (Number.isNaN(parsed)) {
throw new Error(`Variabile ${key} non è un numero valido: "${raw}"`);
}
return parsed;
}
// Validazione dell'ambiente
const nodeEnv = requireEnv("NODE_ENV");
if (nodeEnv !== "development" && nodeEnv !== "production" && nodeEnv !== "test") {
throw new Error(`NODE_ENV non valido: "${nodeEnv}"`);
}
return {
port: requireEnvAsInt("PORT", 3000),
nodeEnv,
database: {
host: requireEnv("DB_HOST"),
port: requireEnvAsInt("DB_PORT", 5432),
name: requireEnv("DB_NAME"),
user: requireEnv("DB_USER"),
password: requireEnv("DB_PASSWORD"),
poolSize: requireEnvAsInt("DB_POOL_SIZE", 10),
},
jwt: {
secret: requireEnv("JWT_SECRET"),
expiresIn: process.env["JWT_EXPIRES_IN"] ?? "1h",
},
cors: {
origins: (process.env["CORS_ORIGINS"] ?? "").split(",").filter(Boolean),
},
};
}
// Esportazione come singleton: la configurazione viene caricata una sola volta
export const config = loadConfig();
Questo pattern garantisce che, se l'applicazione si avvia senza errori, la configurazione è completa e ben formata. Qualsiasi variabile mancante causa un crash immediato all'avvio con un messaggio chiaro, anziché un errore misterioso a runtime quando quella variabile viene usata per la prima volta.
Gestione degli errori
La gestione degli errori in Node.js con TypeScript merita un'attenzione particolare. JavaScript permette di lanciare qualsiasi valore, non solo istanze di Error. TypeScript tipizza il parametro di catch come unknown, il che è corretto ma richiede sempre una verifica esplicita.
Gerarchia di errori personalizzati
// Classe base per tutti gli errori dell'applicazione
class AppError extends Error {
public readonly statusCode: number;
public readonly isOperational: boolean;
public readonly code: string;
constructor(message: string, statusCode: number, code: string, isOperational = true) {
super(message);
// Necessario per il corretto funzionamento di instanceof con le sottoclassi
Object.setPrototypeOf(this, new.target.prototype);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.isOperational = isOperational;
this.code = code;
}
}
// Errori specifici
class NotFoundError extends AppError {
constructor(resource: string, identifier: string) {
super(
`${resource} con identificativo "${identifier}" non trovato`,
404,
"RESOURCE_NOT_FOUND"
);
}
}
class ValidationError extends AppError {
public readonly fields: Record<string, string[]>;
constructor(fields: Record<string, string[]>) {
// Costruzione del messaggio dalla mappa dei campi
const summary = Object.entries(fields)
.map(([field, errors]) => `${field}: ${errors.join(", ")}`)
.join("; ");
super(`Errori di validazione: ${summary}`, 400, "VALIDATION_ERROR");
this.fields = fields;
}
}
class ConflictError extends AppError {
constructor(message: string) {
super(message, 409, "CONFLICT");
}
}
class UnauthorizedError extends AppError {
constructor(message = "Autenticazione richiesta") {
super(message, 401, "UNAUTHORIZED");
}
}
class ForbiddenError extends AppError {
constructor(message = "Permessi insufficienti") {
super(message, 403, "FORBIDDEN");
}
}
La distinzione tra errori operazionali e di programmazione è cruciale. Un errore operazionale (risorsa non trovata, input non valido, servizio esterno non raggiungibile) è prevedibile e gestibile: l'applicazione può restituire una risposta di errore appropriata e continuare a funzionare. Un errore di programmazione (accesso a proprietà di null, tipo inatteso, stato inconsistente) indica un bug: l'unica risposta sicura è terminare il processo e lasciare che l'orchestratore lo riavvii.
Middleware di gestione errori per Express
import type { Request, Response, NextFunction } from "express";
// Interfaccia per la risposta di errore
interface ErrorResponse {
status: "error";
code: string;
message: string;
fields?: Record<string, string[]>;
}
// Middleware centralizzato per la gestione degli errori
function errorHandler(
err: unknown,
_req: Request,
res: Response,
_next: NextFunction
): void {
// Errori operazionali noti
if (err instanceof AppError) {
const body: ErrorResponse = {
status: "error",
code: err.code,
message: err.message,
};
// Aggiunta dei dettagli di validazione se presenti
if (err instanceof ValidationError) {
body.fields = err.fields;
}
res.status(err.statusCode).json(body);
return;
}
// Errori di parsing JSON di Express
if (isSyntaxError(err)) {
res.status(400).json({
status: "error",
code: "INVALID_JSON",
message: "Il corpo della richiesta contiene JSON non valido",
});
return;
}
// Tutto il resto è un errore di programmazione
console.error("Errore non gestito:", err);
res.status(500).json({
status: "error",
code: "INTERNAL_ERROR",
message: "Errore interno del server",
});
}
// Type guard per gli errori di sintassi JSON
function isSyntaxError(err: unknown): err is SyntaxError & { status: number } {
return err instanceof SyntaxError && "status" in err;
}
Pattern per i servizi
Il layer dei servizi contiene la logica di business dell'applicazione. Ogni servizio dovrebbe essere una classe o un modulo che incapsula operazioni coerenti su un dominio specifico, con dipendenze iniettate tramite il costruttore.
import { hash, verify } from "argon2";
// Interfaccia del repository (contratto per l'accesso ai dati)
interface UserRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
create(data: Omit<User, "id" | "createdAt" | "updatedAt">): Promise<User>;
update(id: string, data: Partial<User>): Promise<User>;
delete(id: string): Promise<void>;
}
// Interfaccia del servizio email
interface EmailService {
sendWelcome(to: string, username: string): Promise<void>;
}
// Servizio utente con dependency injection
class UserService {
constructor(
private readonly userRepo: UserRepository,
private readonly emailService: EmailService
) {}
async createUser(input: CreateUserInput): Promise<UserResponse> {
// Verifica unicità email
const existingUser = await this.userRepo.findByEmail(input.email);
if (existingUser !== null) {
throw new ConflictError(`L'indirizzo email "${input.email}" è già registrato`);
}
// Hashing della password
const passwordHash = await hash(input.password);
// Creazione dell'utente
const user = await this.userRepo.create({
email: input.email,
username: input.username,
passwordHash,
role: input.role,
profile: input.profile,
});
// Invio email di benvenuto (non bloccante)
this.emailService
.sendWelcome(user.email, user.username)
.catch((err: unknown) => {
console.error("Invio email di benvenuto fallito:", err);
});
// Rimozione del campo sensibile dalla risposta
const { passwordHash: _, ...response } = user;
return response;
}
async getUserById(id: string): Promise<UserResponse> {
const user = await this.userRepo.findById(id);
if (user === null) {
throw new NotFoundError("Utente", id);
}
const { passwordHash: _, ...response } = user;
return response;
}
async authenticate(email: string, password: string): Promise<UserResponse> {
const user = await this.userRepo.findByEmail(email);
if (user === null) {
// Messaggio generico per non rivelare l'esistenza dell'account
throw new UnauthorizedError("Credenziali non valide");
}
const isValid = await verify(user.passwordHash, password);
if (!isValid) {
throw new UnauthorizedError("Credenziali non valide");
}
const { passwordHash: _, ...response } = user;
return response;
}
}
L'uso di interfacce per le dipendenze (UserRepository, EmailService) anziché di implementazioni concrete è un principio fondamentale. Permette di sostituire le implementazioni in fase di test con dei mock, di cambiare database senza modificare la logica di business e di rispettare il principio di inversione delle dipendenze.
Middleware e tipizzazione delle richieste
Uno dei problemi più comuni con Express e TypeScript è la tipizzazione delle richieste. Per default, req.body è tipizzato come any, il che vanifica i benefici di TypeScript. La soluzione consiste nel validare e tipizzare i dati il prima possibile nella catena di middleware.
import type { Request, Response, NextFunction } from "express";
// Tipo per una funzione di validazione generica
type Validator<T> = (data: unknown) => T;
// Middleware factory che valida il body e lo tipizza
function validateBody<T>(validate: Validator<T>) {
return (req: Request, _res: Response, next: NextFunction): void => {
try {
// Il body validato viene assegnato a una proprietà tipizzata
req.body = validate(req.body);
next();
} catch (err: unknown) {
next(err);
}
};
}
// Esempio di validatore per la creazione utente
function validateCreateUser(data: unknown): CreateUserInput {
// Verifica che il dato sia un oggetto
if (typeof data !== "object" || data === null) {
throw new ValidationError({ body: ["Il corpo della richiesta deve essere un oggetto"] });
}
const obj = data as Record<string, unknown>;
const errors: Record<string, string[]> = {};
// Validazione email
if (typeof obj["email"] !== "string" || !obj["email"].includes("@")) {
errors["email"] = ["L'indirizzo email non è valido"];
}
// Validazione username
if (typeof obj["username"] !== "string" || obj["username"].length < 3) {
errors["username"] = ["Il nome utente deve contenere almeno 3 caratteri"];
}
// Validazione password
if (typeof obj["password"] !== "string" || obj["password"].length < 8) {
errors["password"] = ["La password deve contenere almeno 8 caratteri"];
}
// Se ci sono errori, lancio un'eccezione
if (Object.keys(errors).length > 0) {
throw new ValidationError(errors);
}
// A questo punto il compilatore non sa ancora il tipo,
// ma la validazione ci garantisce la correttezza
return data as CreateUserInput;
}
Per progetti più grandi, librerie come Zod offrono un approccio più elegante. Zod permette di definire lo schema di validazione e di inferire automaticamente il tipo TypeScript corrispondente, eliminando ogni possibilità di disallineamento tra validazione e tipizzazione.
import { z } from "zod";
// Lo schema Zod funge sia da validatore che da definizione di tipo
const CreateUserSchema = z.object({
email: z.string().email("Indirizzo email non valido"),
username: z.string().min(3, "Il nome utente deve avere almeno 3 caratteri"),
password: z.string().min(8, "La password deve avere almeno 8 caratteri"),
role: z.nativeEnum(UserRole),
profile: z.object({
firstName: z.string().min(1, "Il nome è obbligatorio"),
lastName: z.string().min(1, "Il cognome è obbligatorio"),
avatarUrl: z.string().url().nullable(),
bio: z.string().max(500).optional(),
}),
});
// Il tipo viene inferito dallo schema: nessuna duplicazione
type CreateUserInput = z.infer<typeof CreateUserSchema>;
// Middleware generico con Zod
function validateWithSchema<T>(schema: z.ZodSchema<T>) {
return (req: Request, _res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.body);
if (!result.success) {
// Trasformazione degli errori Zod nel formato dell'applicazione
const fields: Record<string, string[]> = {};
for (const issue of result.error.issues) {
const path = issue.path.join(".");
const key = path || "body";
if (!(key in fields)) {
fields[key] = [];
}
fields[key].push(issue.message);
}
next(new ValidationError(fields));
return;
}
req.body = result.data;
next();
};
}
Programmazione asincrona tipizzata
Node.js è costruito attorno al modello asincrono non bloccante, e TypeScript ne migliora la gestione rendendola più sicura. Le Promise generiche, async/await e i tipi condizionali permettono di esprimere flussi asincroni complessi con garanzie di tipo complete.
// Funzione di retry generica con backoff esponenziale
interface RetryOptions {
maxAttempts: number;
baseDelay: number;
maxDelay: number;
shouldRetry?: (error: unknown, attempt: number) => boolean;
}
async function withRetry<T>(
fn: () => Promise<T>,
options: RetryOptions
): Promise<T> {
const { maxAttempts, baseDelay, maxDelay, shouldRetry } = options;
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err: unknown) {
lastError = err;
// Verifica se il retry è permesso
if (shouldRetry !== undefined && !shouldRetry(err, attempt)) {
throw err;
}
// Se è l'ultimo tentativo, non aspettare
if (attempt === maxAttempts) break;
// Calcolo del delay con jitter
const exponentialDelay = baseDelay * Math.pow(2, attempt - 1);
const jitter = Math.random() * 0.3 * exponentialDelay;
const delay = Math.min(exponentialDelay + jitter, maxDelay);
console.warn(
`Tentativo ${attempt}/${maxAttempts} fallito. ` +
`Nuovo tentativo tra ${Math.round(delay)}ms`
);
await new Promise<void>((resolve) => setTimeout(resolve, delay));
}
}
throw lastError;
}
// Esempio di utilizzo con un client HTTP
async function fetchUserFromExternalApi(userId: string): Promise<User> {
return withRetry(
async () => {
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) {
throw new Error(`Risposta HTTP non valida: ${response.status}`);
}
// Casting sicuro dopo la validazione
const data: unknown = await response.json();
return parseUserResponse(data);
},
{
maxAttempts: 3,
baseDelay: 500,
maxDelay: 5000,
// Retry solo su errori di rete o 5xx
shouldRetry: (err) => {
if (err instanceof Error && err.message.includes("5")) return true;
if (err instanceof TypeError) return true;
return false;
},
}
);
}
Concorrenza controllata
// Esecuzione parallela con limite di concorrenza
async function mapWithConcurrency<T, R>(
items: T[],
concurrencyLimit: number,
fn: (item: T, index: number) => Promise<R>
): Promise<R[]> {
const results: R[] = new Array(items.length);
let nextIndex = 0;
// Ogni worker preleva il prossimo elemento dalla coda
async function worker(): Promise<void> {
while (nextIndex < items.length) {
const currentIndex = nextIndex;
nextIndex += 1;
results[currentIndex] = await fn(items[currentIndex], currentIndex);
}
}
// Avvio dei worker paralleli
const workerCount = Math.min(concurrencyLimit, items.length);
const workers = Array.from({ length: workerCount }, () => worker());
await Promise.all(workers);
return results;
}
// Esempio: elaborazione di 1000 immagini con al massimo 5 operazioni parallele
const processedImages = await mapWithConcurrency(
imageUrls,
5,
async (url, index) => {
console.log(`Elaborazione immagine ${index + 1}/${imageUrls.length}`);
const buffer = await downloadImage(url);
return resizeImage(buffer, { width: 800, height: 600 });
}
);
Pattern avanzati di tipizzazione
TypeScript offre feature avanzate che permettono di modellare concetti complessi a livello di tipo. Questi pattern sono particolarmente utili nelle applicazioni Node.js di grandi dimensioni.
Branded types
Un problema comune è l'uso intercambiabile di valori che hanno lo stesso tipo primitivo ma significati diversi: un ID utente e un ID ordine sono entrambi stringhe, ma passare uno al posto dell'altro è un bug. I branded types risolvono questo problema.
// Definizione di tipi "etichettati" per gli identificativi
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type ProductId = Brand<string, "ProductId">;
// Funzioni di costruzione che validano e "marchiano" il valore
function toUserId(value: string): UserId {
if (!value.startsWith("usr_")) {
throw new Error(`ID utente non valido: deve iniziare con "usr_"`);
}
return value as UserId;
}
function toOrderId(value: string): OrderId {
if (!value.startsWith("ord_")) {
throw new Error(`ID ordine non valido: deve iniziare con "ord_"`);
}
return value as OrderId;
}
// Ora il compilatore impedisce lo scambio accidentale
function getOrder(orderId: OrderId): Promise<Order> {
// ...
return Promise.resolve({} as Order);
}
const userId = toUserId("usr_abc123");
const orderId = toOrderId("ord_xyz789");
// Errore di compilazione: il tipo UserId non è assegnabile a OrderId
// getOrder(userId)
// Corretto
getOrder(orderId);
Type-safe event emitter
L'EventEmitter di Node.js è un pattern pervasivo ma intrinsecamente non tipizzato. Con TypeScript è possibile costruire una versione completamente tipizzata che garantisce la corrispondenza tra nomi degli eventi e tipi dei dati associati.
import { EventEmitter } from "node:events";
// Mappa degli eventi e dei loro payload
interface OrderEvents {
"order:created": { orderId: OrderId; userId: UserId; total: number };
"order:paid": { orderId: OrderId; paymentId: string };
"order:shipped": { orderId: OrderId; trackingNumber: string };
"order:cancelled": { orderId: OrderId; reason: string };
}
// EventEmitter tipizzato
class TypedEventEmitter<TEvents extends Record<string, unknown>> {
private emitter = new EventEmitter();
on<K extends keyof TEvents & string>(
event: K,
listener: (payload: TEvents[K]) => void
): this {
this.emitter.on(event, listener as (...args: unknown[]) => void);
return this;
}
emit<K extends keyof TEvents & string>(event: K, payload: TEvents[K]): boolean {
return this.emitter.emit(event, payload);
}
off<K extends keyof TEvents & string>(
event: K,
listener: (payload: TEvents[K]) => void
): this {
this.emitter.off(event, listener as (...args: unknown[]) => void);
return this;
}
}
// Utilizzo con piena sicurezza dei tipi
const orderBus = new TypedEventEmitter<OrderEvents>();
// Il compilatore conosce il tipo esatto del payload
orderBus.on("order:created", (payload) => {
// payload è tipizzato come { orderId: OrderId; userId: UserId; total: number }
console.log(`Ordine ${payload.orderId} creato per l'utente ${payload.userId}`);
});
// Errore di compilazione se il payload non corrisponde
// orderBus.emit("order:created", { orderId: "wrong" });
Testing
Il testing è un ambito in cui TypeScript dà il meglio di sé. I tipi guidano la scrittura dei test, l'autocompletamento suggerisce i metodi disponibili sui mock e il compilatore segnala immediatamente se un test diventa obsoleto dopo un refactoring.
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock tipizzato del repository
function createMockUserRepo(): UserRepository {
return {
findById: vi.fn(),
findByEmail: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
}
// Mock tipizzato del servizio email
function createMockEmailService(): EmailService {
return {
sendWelcome: vi.fn().mockResolvedValue(undefined),
};
}
describe("UserService", () => {
let userService: UserService;
let mockUserRepo: ReturnType<typeof createMockUserRepo>;
let mockEmailService: ReturnType<typeof createMockEmailService>;
beforeEach(() => {
mockUserRepo = createMockUserRepo();
mockEmailService = createMockEmailService();
userService = new UserService(mockUserRepo, mockEmailService);
});
describe("createUser", () => {
const validInput: CreateUserInput = {
email: "test@example.com",
username: "testuser",
password: "password123",
role: UserRole.Viewer,
profile: {
firstName: "Mario",
lastName: "Rossi",
avatarUrl: null,
},
};
it("crea un utente e invia l'email di benvenuto", async () => {
// Preparazione: l'email non è già registrata
vi.mocked(mockUserRepo.findByEmail).mockResolvedValue(null);
// Preparazione: il repository restituisce l'utente creato
const createdUser: User = {
id: "usr_123",
...validInput,
passwordHash: "hashed_password",
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(mockUserRepo.create).mockResolvedValue(createdUser);
// Esecuzione
const result = await userService.createUser(validInput);
// Verifica: la password non è presente nella risposta
expect(result).not.toHaveProperty("passwordHash");
expect(result.email).toBe("test@example.com");
// Verifica: l'email di benvenuto è stata inviata
expect(mockEmailService.sendWelcome).toHaveBeenCalledWith(
"test@example.com",
"testuser"
);
});
it("lancia ConflictError se l'email è già registrata", async () => {
// Preparazione: l'email è già nel database
vi.mocked(mockUserRepo.findByEmail).mockResolvedValue({
id: "usr_existing",
} as User);
// Verifica che venga lanciato l'errore corretto
await expect(userService.createUser(validInput))
.rejects
.toThrow(ConflictError);
});
});
describe("authenticate", () => {
it("lancia UnauthorizedError per email inesistente", async () => {
vi.mocked(mockUserRepo.findByEmail).mockResolvedValue(null);
await expect(
userService.authenticate("nonexistent@example.com", "password")
).rejects.toThrow(UnauthorizedError);
});
});
});
La funzione vi.mocked() di Vitest restituisce un riferimento tipizzato al mock, permettendo di usare metodi come mockResolvedValue con il tipo corretto del valore restituito. Se l'interfaccia UserRepository cambia, tutti i test che la usano mostreranno immediatamente errori di compilazione.
Esecuzione e strumenti di sviluppo
L'ecosistema di tool per l'esecuzione di TypeScript su Node.js si è evoluto notevolmente. Oggi esistono diverse opzioni, ciascuna con vantaggi specifici.
tsx per lo sviluppo
tsx è lo strumento più pratico per lo sviluppo quotidiano. Esegue file TypeScript direttamente senza richiedere una fase di compilazione esplicita, supporta ESM e CommonJS, e include il watch mode per il riavvio automatico.
# Installazione
npm install --save-dev tsx
# Esecuzione diretta
npx tsx src/index.ts
# Watch mode: riavvio automatico ad ogni modifica
npx tsx watch src/index.ts
Script nel package.json
{
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint src/ --ext .ts"
}
}
La separazione tra dev (esecuzione diretta con tsx), build (compilazione con tsc) e start (esecuzione del JavaScript compilato) è il pattern standard. In produzione si esegue sempre il codice compilato per ottenere le migliori prestazioni.
Il flag nativo --experimental-strip-types
A partire dalla versione 22.6, Node.js include un supporto sperimentale per l'esecuzione diretta di file TypeScript tramite il flag --experimental-strip-types. Questo flag rimuove le annotazioni di tipo senza eseguire alcuna trasformazione o controllo, il che significa che il codice viene eseguito come JavaScript con i tipi semplicemente eliminati.
# Esecuzione nativa (Node.js 22.6+)
node --experimental-strip-types src/index.ts
Questa funzionalità ha alcune limitazioni importanti: non supporta i decoratori, gli enum con inizializzatori numerici impliciti, i namespace e altre feature di TypeScript che richiedono trasformazioni del codice. Per progetti che usano queste funzionalità, tsx o la compilazione tradizionale con tsc restano la scelta più affidabile.
ESM vs CommonJS
La coesistenza di ESM (ECMAScript Modules) e CommonJS è uno degli aspetti più confusi dell'ecosistema Node.js. TypeScript aggiunge un ulteriore livello di complessità. La scelta del sistema di moduli dipende dal progetto, ma è fondamentale comprendere le differenze.
// ===== CommonJS (il default storico) =====
// Importazione
const { readFile } = require("node:fs/promises");
// Esportazione
module.exports = { myFunction };
// ===== ESM (lo standard moderno) =====
// Importazione
import { readFile } from "node:fs/promises";
// Esportazione
export function myFunction(): void {
// ...
}
Per usare ESM con TypeScript, occorre impostare "type": "module" nel package.json e configurare "module": "Node16" nel tsconfig.json. Con questa configurazione, le importazioni devono includere l'estensione .js (non .ts), perché TypeScript emette codice che verrà eseguito da Node.js, il quale richiede estensioni esplicite in ESM.
// In modalità ESM con Node16, le importazioni richiedono l'estensione .js
// anche se il file sorgente è .ts
import { config } from "./config/environment.js";
import { UserService } from "./services/userService.js";
La raccomandazione attuale per nuovi progetti è di adottare ESM. L'ecosistema si sta muovendo in questa direzione, molte librerie hanno già abbandonato il supporto CommonJS e Node.js continua a migliorare il supporto ESM nativo.
Integrazione con il database
La connessione al database è un caso d'uso dove TypeScript eccelle, perché permette di tipizzare le query e i risultati, riducendo drasticamente gli errori legati a nomi di colonna sbagliati, tipi di dato errati o campi mancanti.
import { Pool } from "pg";
// Interfaccia per le righe della tabella utenti
interface UserRow {
id: string;
email: string;
username: string;
password_hash: string;
role: string;
first_name: string;
last_name: string;
avatar_url: string | null;
bio: string | null;
created_at: Date;
updated_at: Date;
}
// Repository con query parametrizzate e tipizzate
class PostgresUserRepository implements UserRepository {
constructor(private readonly pool: Pool) {}
async findById(id: string): Promise<User | null> {
const query = `
SELECT id, email, username, password_hash, role,
first_name, last_name, avatar_url, bio,
created_at, updated_at
FROM users
WHERE id = $1
`;
const result = await this.pool.query<UserRow>(query, [id]);
if (result.rows.length === 0) {
return null;
}
// Mappatura dalla riga del database al modello dell'applicazione
return this.mapRowToUser(result.rows[0]);
}
async findByEmail(email: string): Promise<User | null> {
const query = `SELECT * FROM users WHERE email = $1`;
const result = await this.pool.query<UserRow>(query, [email]);
if (result.rows.length === 0) {
return null;
}
return this.mapRowToUser(result.rows[0]);
}
async create(data: Omit<User, "id" | "createdAt" | "updatedAt">): Promise<User> {
const query = `
INSERT INTO users (email, username, password_hash, role, first_name, last_name, avatar_url, bio)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *
`;
const values = [
data.email,
data.username,
data.passwordHash,
data.role,
data.profile.firstName,
data.profile.lastName,
data.profile.avatarUrl,
data.profile.bio ?? null,
];
const result = await this.pool.query<UserRow>(query, values);
return this.mapRowToUser(result.rows[0]);
}
// Mappatura snake_case -> camelCase con conversione di tipo
private mapRowToUser(row: UserRow): User {
return {
id: row.id,
email: row.email,
username: row.username,
passwordHash: row.password_hash,
role: row.role as UserRole,
profile: {
firstName: row.first_name,
lastName: row.last_name,
avatarUrl: row.avatar_url,
bio: row.bio ?? undefined,
},
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
}
Per progetti più complessi, ORM come Prisma o query builder come Kysely offrono tipizzazione automatica delle query basata sullo schema del database, eliminando la necessità di definire manualmente le interfacce per le righe e le funzioni di mappatura.
Graceful shutdown
Un'applicazione Node.js in produzione deve gestire lo spegnimento in modo pulito: completare le richieste in corso, chiudere le connessioni al database, rilasciare le risorse e terminare senza perdere dati. TypeScript aiuta a strutturare questa logica in modo chiaro.
import { Server } from "node:http";
// Registro delle risorse da chiudere
interface Closeable {
close(): Promise<void>;
}
class GracefulShutdown {
private readonly resources: Closeable[] = [];
private isShuttingDown = false;
// Registrazione di una risorsa che deve essere chiusa allo spegnimento
register(resource: Closeable): void {
this.resources.push(resource);
}
// Registrazione del server HTTP con timeout
registerHttpServer(server: Server, timeoutMs = 30_000): void {
this.register({
close: () =>
new Promise<void>((resolve, reject) => {
// Smette di accettare nuove connessioni
server.close((err) => {
if (err) reject(err);
else resolve();
});
// Timeout di sicurezza
setTimeout(() => {
console.warn("Timeout dello spegnimento: chiusura forzata del server");
resolve();
}, timeoutMs);
}),
});
}
// Inizializzazione dei listener per i segnali del sistema operativo
listen(): void {
const shutdown = async (signal: string): Promise<void> => {
if (this.isShuttingDown) return;
this.isShuttingDown = true;
console.log(`\nSegnale ${signal} ricevuto. Avvio spegnimento ordinato...`);
// Chiusura delle risorse in ordine inverso di registrazione
const reversed = [...this.resources].reverse();
for (const resource of reversed) {
try {
await resource.close();
} catch (err: unknown) {
console.error("Errore durante la chiusura di una risorsa:", err);
}
}
console.log("Spegnimento completato.");
process.exit(0);
};
process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM"));
}
}
Il punto di ingresso dell'applicazione
Il file index.ts assembla tutti i componenti, inizializza le dipendenze e avvia il server. Questo è il punto in cui la dependency injection prende forma concretamente.
import express from "express";
import { Pool } from "pg";
import { config } from "./config/environment.js";
async function main(): Promise<void> {
// Creazione del pool di connessioni al database
const pool = new Pool({
host: config.database.host,
port: config.database.port,
database: config.database.name,
user: config.database.user,
password: config.database.password,
max: config.database.poolSize,
});
// Verifica della connessione al database
await pool.query("SELECT 1");
console.log("Connessione al database stabilita");
// Composizione delle dipendenze
const userRepo = new PostgresUserRepository(pool);
const emailService = new SmtpEmailService(config);
const userService = new UserService(userRepo, emailService);
// Configurazione di Express
const app = express();
app.use(express.json({ limit: "1mb" }));
// Registrazione delle rotte
app.post("/users", validateWithSchema(CreateUserSchema), async (req, res, next) => {
try {
const user = await userService.createUser(req.body);
res.status(201).json(user);
} catch (err: unknown) {
next(err);
}
});
app.get("/users/:id", async (req, res, next) => {
try {
const user = await userService.getUserById(req.params.id);
res.json(user);
} catch (err: unknown) {
next(err);
}
});
// Middleware di gestione errori (sempre per ultimo)
app.use(errorHandler);
// Avvio del server
const server = app.listen(config.port, () => {
console.log(`Server in ascolto sulla porta ${config.port}`);
});
// Configurazione dello spegnimento ordinato
const shutdown = new GracefulShutdown();
shutdown.registerHttpServer(server);
shutdown.register({ close: () => pool.end() });
shutdown.listen();
}
// Avvio dell'applicazione con gestione dell'errore fatale
main().catch((err: unknown) => {
console.error("Errore fatale durante l'avvio:", err);
process.exit(1);
});
Considerazioni finali
L'adozione di TypeScript in un progetto Node.js non è semplicemente l'aggiunta di annotazioni di tipo al codice esistente. È un cambio di paradigma che influenza l'architettura, il modo in cui si modellano i dati, si gestiscono gli errori, si strutturano le dipendenze e si scrivono i test. Il costo iniziale di configurazione e della curva di apprendimento viene ripagato rapidamente dalla riduzione dei bug in produzione, dalla velocità di refactoring e dalla chiarezza della codebase.
Gli aspetti coperti in questo articolo — la configurazione rigorosa del compilatore, la gerarchia degli errori, i pattern di tipizzazione avanzata come le discriminated unions e i branded types, la validazione con Zod, il testing con mock tipizzati e il graceful shutdown — rappresentano le fondamenta su cui costruire applicazioni Node.js professionali. Ogni progetto avrà le sue specificità, ma questi principi restano costanti indipendentemente dalla scala e dal dominio applicativo.
TypeScript continua a evolversi a ritmo sostenuto. Feature come i decoratori (ora in Stage 3), il pattern matching (proposta attiva) e il supporto nativo di Node.js per la rimozione dei tipi stanno progressivamente eliminando le frizioni residue. Il futuro del backend JavaScript è tipizzato, e il momento migliore per abbracciarlo è adesso.