DTO con TypeScript

Il Data Transfer Object, comunemente abbreviato in DTO, è un pattern architetturale il cui scopo è trasportare dati tra i confini di un sistema. Non contiene logica di business, non esegue calcoli, non accede a database. È, nella sua essenza, un contenitore di dati con una forma ben definita. In TypeScript, questo pattern trova un terreno particolarmente fertile grazie al sistema di tipi statici del linguaggio, che permette di esprimere la struttura di un DTO con precisione chirurgica.

L'idea nasce da un problema concreto: quando un client comunica con un server, o quando un layer applicativo comunica con un altro, i dati che attraversano quel confine hanno bisogno di una forma esplicita. Il modello di dominio interno può essere ricco, complesso, pieno di metodi e relazioni. Ma ciò che viaggia sulla rete, o ciò che viene passato a un template, deve essere piatto, serializzabile e privo di comportamento. Il DTO è esattamente questo: una proiezione semplificata del dominio, pensata per il transito.

Perché non basta un'interfaccia qualsiasi

La tentazione, in TypeScript, è dichiarare un'interfaccia e considerare il lavoro concluso. In fondo, un'interfaccia descrive già una forma. Ma c'è una differenza sostanziale tra descrivere una forma e garantire che quella forma venga rispettata a runtime. Le interfacce TypeScript esistono solo a compile-time: vengono cancellate durante la transpilazione e non lasciano alcuna traccia nel JavaScript risultante. Questo significa che non possono validare dati in ingresso, non possono trasformare valori, non possono rifiutare payload malformati.

Un DTO, nel senso pieno del termine, deve poter fare almeno due cose: dichiarare la propria forma e, idealmente, imporre quella forma sui dati che riceve. In TypeScript possiamo ottenere entrambe le cose combinando il sistema di tipi con classi concrete o funzioni di validazione.

La struttura di base: DTO come classe

L'approccio più diretto consiste nel definire il DTO come una classe con proprietà readonly. In questo modo si ottiene un oggetto immutabile la cui struttura è verificabile sia a compile-time sia, con qualche accorgimento, a runtime.

// DTO per la creazione di un utente
class CreateUserDto {
  readonly name: string;
  readonly email: string;
  readonly age: number;

  constructor(name: string, email: string, age: number) {
    this.name = name;
    this.email = email;
    this.age = age;
  }
}

Le proprietà sono marcate come readonly per comunicare un'intenzione precisa: questo oggetto non va modificato dopo la creazione. Chi riceve un CreateUserDto sa che i dati al suo interno sono stabili. Questo è un contratto, non un suggerimento.

Si può rendere la costruzione più concisa sfruttando i parameter properties di TypeScript, una sintassi che dichiara e assegna le proprietà direttamente nella firma del costruttore:

// Versione compatta con parameter properties
class CreateUserDto {
  constructor(
    readonly name: string,
    readonly email: string,
    readonly age: number
  ) {}
}

Il risultato è identico alla versione precedente, ma il codice è più denso e meno cerimonioso. In contesti dove i DTO sono numerosi, questa brevità conta.

DTO come interfaccia con factory function

Non tutti preferiscono le classi. Un approccio alternativo, più funzionale, consiste nel definire il DTO come interfaccia e costruirlo attraverso una funzione factory. Questo elimina il legame con new e produce oggetti plain, facilmente serializzabili.

// Definizione della forma del DTO
interface CreateUserDto {
  readonly name: string;
  readonly email: string;
  readonly age: number;
}

// Funzione factory per costruire il DTO
function createUserDto(
  name: string,
  email: string,
  age: number
): CreateUserDto {
  return Object.freeze({ name, email, age });
}

Object.freeze aggiunge una garanzia a runtime: qualsiasi tentativo di modificare le proprietà dell'oggetto restituito verrà ignorato silenziosamente in modalità non-strict, o lancerà un errore in strict mode. Combinato con readonly a livello di tipo, si ottiene immutabilità su entrambi i piani.

Separare i DTO per direzione

Un errore comune è usare un unico tipo per rappresentare sia i dati in ingresso sia quelli in uscita. In realtà, le due direzioni hanno esigenze diverse. Quando un client invia dati per creare una risorsa, certi campi non esistono ancora: l'id viene generato dal server, il timestamp di creazione viene assegnato automaticamente, lo stato iniziale è implicito. Quando il server risponde, quei campi sono presenti e obbligatori.

// DTO per la richiesta di creazione (dal client al server)
interface CreateArticleRequestDto {
  readonly title: string;
  readonly body: string;
  readonly tags: readonly string[];
}

// DTO per la risposta dopo la creazione (dal server al client)
interface ArticleResponseDto {
  readonly id: string;
  readonly title: string;
  readonly body: string;
  readonly tags: readonly string[];
  readonly createdAt: string;
  readonly updatedAt: string;
}

// DTO per la richiesta di aggiornamento (tutti i campi opzionali)
interface UpdateArticleRequestDto {
  readonly title?: string;
  readonly body?: string;
  readonly tags?: readonly string[];
}

Tre tipi distinti per tre momenti distinti. CreateArticleRequestDto rappresenta l'intenzione di creare, UpdateArticleRequestDto l'intenzione di modificare parzialmente, ArticleResponseDto il dato consolidato che il server restituisce. Questa separazione non è burocrazia: è la traduzione fedele di ciò che accade realmente nel sistema.

Si noti l'uso di readonly string[] per l'array di tag. Questo impedisce operazioni come push o splice sull'array, mantenendo l'immutabilità anche nelle strutture annidate.

Utility types al servizio dei DTO

TypeScript offre una serie di utility types che possono ridurre drasticamente la duplicazione quando si definiscono famiglie di DTO correlati. Partial, Pick, Omit e Readonly sono strumenti fondamentali in questo contesto.

// Tipo base con tutte le proprietà dell'articolo
interface ArticleProperties {
  id: string;
  title: string;
  body: string;
  tags: string[];
  authorId: string;
  createdAt: string;
  updatedAt: string;
}

// DTO di risposta: tutte le proprietà, in sola lettura
type ArticleResponseDto = Readonly<ArticleProperties>;

// DTO di creazione: esclude i campi generati dal server
type CreateArticleDto = Readonly<Omit<
  ArticleProperties,
  "id" | "createdAt" | "updatedAt"
>>;

// DTO di aggiornamento: solo i campi modificabili, tutti opzionali
type UpdateArticleDto = Readonly<Partial<Pick<
  ArticleProperties,
  "title" | "body" | "tags"
>>>;

// DTO per le liste: un sottoinsieme leggero per le anteprime
type ArticleSummaryDto = Readonly<Pick<
  ArticleProperties,
  "id" | "title" | "authorId" | "createdAt"
>>;

Tutti e quattro i DTO derivano da un'unica fonte di verità. Se domani si aggiunge un campo slug ad ArticleProperties, i DTO che ne hanno bisogno lo ereditano automaticamente, quelli che lo escludono restano invariati. È manutenzione ridotta e coerenza garantita.

Validazione a runtime

Fino a questo punto, tutto ciò che abbiamo scritto esiste solo a compile-time. Ma i dati che arrivano da un'API, da un form, da un file JSON non sono tipizzati: sono unknown per definizione. Serve un meccanismo che li validi e li trasformi in DTO tipizzati. Qui entrano in gioco librerie come Zod, che permettono di definire schemi di validazione da cui derivare automaticamente i tipi TypeScript.

import { z } from "zod";

// Schema di validazione per la creazione di un 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("L'indirizzo email non è valido"),
  age: z
    .number()
    .int("L'età deve essere un numero intero")
    .min(18, "L'utente deve essere maggiorenne")
    .max(150, "Età non plausibile"),
});

// Il tipo DTO viene inferito dallo schema
type CreateUserDto = z.infer<typeof CreateUserSchema>;

// Funzione che valida dati sconosciuti e restituisce un DTO tipizzato
function parseCreateUserDto(data: unknown): CreateUserDto {
  return CreateUserSchema.parse(data);
}

z.infer estrae il tipo TypeScript dallo schema Zod, eliminando la necessità di dichiarare il tipo separatamente. Lo schema è la singola fonte di verità sia per la validazione runtime sia per il tipo statico. parse lancia un'eccezione se i dati non rispettano lo schema; in alternativa, safeParse restituisce un oggetto discriminato che permette di gestire l'errore senza eccezioni.

// Validazione sicura senza eccezioni
function validateCreateUserDto(data: unknown): CreateUserDto | null {
  const result = CreateUserSchema.safeParse(data);

  if (!result.success) {
    // Qui si possono loggare o restituire gli errori al chiamante
    console.error("Validazione fallita:", result.error.issues);
    return null;
  }

  return result.data;
}

Trasformazione tra modelli di dominio e DTO

Un DTO non è il modello di dominio. Il modello di dominio può contenere metodi, riferimenti circolari, campi interni che non devono uscire dal layer applicativo. La trasformazione da modello a DTO, e viceversa, è un passaggio esplicito che merita il proprio codice dedicato. Il pattern più comune è un mapper, una funzione pura che prende un'entità e restituisce un DTO.

// Entità di dominio (può avere metodi, relazioni, campi privati)
class User {
  constructor(
    readonly id: string,
    readonly name: string,
    readonly email: string,
    readonly passwordHash: string,
    readonly role: "admin" | "editor" | "viewer",
    readonly createdAt: Date,
    readonly lastLoginAt: Date | null
  ) {}

  // Logica di dominio
  isAdmin(): boolean {
    return this.role === "admin";
  }
}

// DTO di risposta (nessun campo sensibile, nessun metodo)
interface UserResponseDto {
  readonly id: string;
  readonly name: string;
  readonly email: string;
  readonly role: string;
  readonly createdAt: string;
  readonly lastLoginAt: string | null;
}

// Mapper: da entità di dominio a DTO
function toUserResponseDto(user: User): UserResponseDto {
  return {
    id: user.id,
    name: user.name,
    email: user.email,
    role: user.role,
    createdAt: user.createdAt.toISOString(),
    lastLoginAt: user.lastLoginAt?.toISOString() ?? null,
  };
}

// Mapper per liste
function toUserResponseDtoList(users: User[]): UserResponseDto[] {
  return users.map(toUserResponseDto);
}

Si noti cosa è successo nella trasformazione: passwordHash è sparito, perché non deve mai raggiungere il client. Le date, che nel dominio sono oggetti Date, sono diventate stringhe ISO 8601, perché è il formato standard per la serializzazione JSON. Il metodo isAdmin non esiste nel DTO, perché un DTO non ha comportamento.

Questi mapper sono funzioni pure: dato lo stesso input producono sempre lo stesso output, senza effetti collaterali. Sono banali da testare, facili da comporre, impossibili da confondere con logica di business.

DTO e generics

Quando un'API restituisce risposte con una struttura comune, come un wrapper di paginazione, i generics permettono di definire DTO riutilizzabili senza sacrificare la tipizzazione.

// DTO generico per risposte paginate
interface PaginatedResponseDto<T> {
  readonly items: readonly T[];
  readonly total: number;
  readonly page: number;
  readonly perPage: number;
  readonly totalPages: number;
}

// Funzione factory per costruire risposte paginate
function toPaginatedResponseDto<TEntity, TDto>(
  entities: TEntity[],
  total: number,
  page: number,
  perPage: number,
  mapper: (entity: TEntity) => TDto
): PaginatedResponseDto<TDto> {
  return {
    items: entities.map(mapper),
    total,
    page,
    perPage,
    totalPages: Math.ceil(total / perPage),
  };
}

// Utilizzo concreto
const paginatedUsers: PaginatedResponseDto<UserResponseDto> =
  toPaginatedResponseDto(users, 100, 1, 20, toUserResponseDto);

PaginatedResponseDto è parametrico sul tipo degli items. La funzione toPaginatedResponseDto accetta un mapper generico, il che la rende applicabile a qualsiasi coppia entità-DTO. Il tipo della risposta finale è completamente inferito: PaginatedResponseDto<UserResponseDto>, senza dover specificare nulla manualmente.

DTO e discriminated unions

Le discriminated unions di TypeScript sono particolarmente utili quando un'API deve restituire risposte con forme diverse in base a un campo discriminante. Un caso tipico sono le notifiche, che condividono alcuni campi ma differiscono per tipo.

// DTO per notifica di tipo "messaggio"
interface MessageNotificationDto {
  readonly kind: "message";
  readonly id: string;
  readonly senderId: string;
  readonly senderName: string;
  readonly preview: string;
  readonly receivedAt: string;
}

// DTO per notifica di tipo "sistema"
interface SystemNotificationDto {
  readonly kind: "system";
  readonly id: string;
  readonly severity: "info" | "warning" | "critical";
  readonly title: string;
  readonly description: string;
  readonly receivedAt: string;
}

// DTO per notifica di tipo "promemoria"
interface ReminderNotificationDto {
  readonly kind: "reminder";
  readonly id: string;
  readonly taskId: string;
  readonly taskTitle: string;
  readonly dueAt: string;
  readonly receivedAt: string;
}

// Unione discriminata: il campo "kind" determina la forma
type NotificationDto =
  | MessageNotificationDto
  | SystemNotificationDto
  | ReminderNotificationDto;

// Il narrowing è automatico grazie al discriminante
function formatNotification(notification: NotificationDto): string {
  switch (notification.kind) {
    case "message":
      // TypeScript sa che qui notification è MessageNotificationDto
      return `Nuovo messaggio da ${notification.senderName}: ${notification.preview}`;
    case "system":
      // Qui è SystemNotificationDto
      return `[${notification.severity.toUpperCase()}] ${notification.title}`;
    case "reminder":
      // Qui è ReminderNotificationDto
      return `Promemoria: ${notification.taskTitle} scade il ${notification.dueAt}`;
  }
}

Il campo kind agisce da discriminante. Quando si esegue un controllo su quel campo, TypeScript restringe automaticamente il tipo all'interno di ogni branch. Non servono cast, non servono asserzioni. Il compilatore sa esattamente quale forma ha l'oggetto in ogni punto del codice.

DTO annidati e composizione

I sistemi reali producono dati con strutture annidate. Un ordine contiene un cliente e una lista di prodotti; un post contiene un autore e una lista di commenti, ciascuno con il proprio autore. La strategia corretta è comporre DTO più piccoli in DTO più grandi, mantenendo ogni pezzo indipendente e riutilizzabile.

// DTO per l'indirizzo
interface AddressDto {
  readonly street: string;
  readonly city: string;
  readonly postalCode: string;
  readonly country: string;
}

// DTO per il prodotto nell'ordine
interface OrderItemDto {
  readonly productId: string;
  readonly productName: string;
  readonly quantity: number;
  readonly unitPrice: number;
  readonly totalPrice: number;
}

// DTO per il cliente (versione leggera, senza dati sensibili)
interface OrderCustomerDto {
  readonly id: string;
  readonly fullName: string;
  readonly email: string;
}

// DTO composto per l'ordine completo
interface OrderResponseDto {
  readonly id: string;
  readonly status: "pending" | "confirmed" | "shipped" | "delivered";
  readonly customer: OrderCustomerDto;
  readonly shippingAddress: AddressDto;
  readonly items: readonly OrderItemDto[];
  readonly subtotal: number;
  readonly tax: number;
  readonly total: number;
  readonly placedAt: string;
}

AddressDto può essere riutilizzato ovunque serva un indirizzo: nell'ordine, nel profilo utente, nella fattura. OrderCustomerDto è una proiezione ridotta del cliente, diversa dal UserResponseDto completo, perché nel contesto di un ordine servono solo poche informazioni. Ogni DTO ha un perimetro preciso, determinato dal contesto in cui viene usato.

Branded types per DTO più sicuri

Un problema ricorrente con i DTO è che stringhe semanticamente diverse condividono lo stesso tipo strutturale. Un userId e un orderId sono entrambi string, ma passare l'uno al posto dell'altro è un errore logico che TypeScript, nella sua forma base, non può intercettare. I branded types risolvono questo problema.

// Tipi nominali tramite branding
type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };
type ProductId = string & { readonly __brand: "ProductId" };

// Funzioni di costruzione per i tipi branded
function toUserId(value: string): UserId {
  return value as UserId;
}

function toOrderId(value: string): OrderId {
  return value as OrderId;
}

// DTO con identificativi fortemente tipizzati
interface OrderResponseDto {
  readonly id: OrderId;
  readonly customerId: UserId;
  readonly items: readonly {
    readonly productId: ProductId;
    readonly quantity: number;
  }[];
}

// Funzione che accetta solo un UserId, non un OrderId
function fetchUserOrders(userId: UserId): Promise<OrderResponseDto[]> {
  // ...implementazione
  return Promise.resolve([]);
}

// Questo compila correttamente
const userId = toUserId("usr_abc123");
fetchUserOrders(userId);

// Questo genera un errore a compile-time
const orderId = toOrderId("ord_xyz789");
// fetchUserOrders(orderId); // Errore: OrderId non è assegnabile a UserId

Il campo __brand non esiste a runtime: è un phantom type che il compilatore usa esclusivamente per distinguere tipi che sarebbero altrimenti identici. Il costo è zero in termini di performance, ma il guadagno in termini di sicurezza è significativo. È una rete di sicurezza che cattura errori prima ancora che il codice venga eseguito.

Serializzazione e deserializzazione

I DTO devono attraversare confini di serializzazione: diventano JSON sulla rete, vengono ricostruiti dall'altra parte. Questo passaggio introduce problemi che i tipi statici non possono risolvere da soli. Il caso più comune è Date: JSON.stringify trasforma un oggetto Date in una stringa ISO 8601, ma JSON.parse non la riconverte automaticamente in un Date. Restituisce una stringa.

import { z } from "zod";

// Schema che gestisce la deserializzazione delle date
const ArticleResponseSchema = z.object({
  id: z.string(),
  title: z.string(),
  body: z.string(),
  tags: z.array(z.string()),
  // Accetta una stringa ISO e la trasforma in Date
  createdAt: z.string().datetime().transform((val) => new Date(val)),
  updatedAt: z.string().datetime().transform((val) => new Date(val)),
});

// Il tipo inferito ha createdAt e updatedAt come Date, non come string
type ArticleResponse = z.infer<typeof ArticleResponseSchema>;

// Funzione per deserializzare la risposta dell'API
async function fetchArticle(id: string): Promise<ArticleResponse> {
  const response = await fetch(`/api/articles/${id}`);
  const rawData: unknown = await response.json();

  // Validazione e trasformazione in un solo passaggio
  return ArticleResponseSchema.parse(rawData);
}

Lo schema Zod con transform esegue sia la validazione sia la trasformazione. Il tipo inferito riflette il risultato della trasformazione, non l'input grezzo. Questo significa che il codice che consuma ArticleResponse lavora direttamente con oggetti Date, senza doversi preoccupare di conversioni manuali sparse nel codebase.

DTO nelle architetture a layer

In un'architettura stratificata, i DTO vivono ai confini tra i layer. Il controller riceve un DTO di richiesta, lo passa al service layer dopo averlo validato, il service layer restituisce un'entità di dominio, il controller la trasforma in un DTO di risposta e la invia al client. Nessun layer conosce i dettagli interni degli altri; i DTO sono il contratto che li collega.

import { z } from "zod";

// --- Schema e tipo del DTO di richiesta ---

const CreateOrderRequestSchema = z.object({
  customerId: z.string().uuid(),
  items: z
    .array(
      z.object({
        productId: z.string().uuid(),
        quantity: z.number().int().positive(),
      })
    )
    .nonempty("L'ordine deve contenere almeno un prodotto"),
  shippingAddressId: z.string().uuid(),
  notes: z.string().max(500).optional(),
});

type CreateOrderRequestDto = z.infer<typeof CreateOrderRequestSchema>;

// --- DTO di risposta ---

interface CreateOrderResponseDto {
  readonly id: string;
  readonly status: string;
  readonly estimatedDelivery: string;
  readonly total: number;
}

// --- Mapper ---

function toCreateOrderResponseDto(order: Order): CreateOrderResponseDto {
  return {
    id: order.id,
    status: order.status,
    estimatedDelivery: order.estimatedDelivery.toISOString(),
    total: order.total,
  };
}

// --- Controller (esempio con Express) ---

async function createOrderHandler(req: Request, res: Response): Promise<void> {
  // Validazione del body della richiesta
  const parseResult = CreateOrderRequestSchema.safeParse(req.body);

  if (!parseResult.success) {
    res.status(400).json({
      errors: parseResult.error.issues,
    });
    return;
  }

  // Il DTO validato viene passato al service layer
  const dto: CreateOrderRequestDto = parseResult.data;
  const order = await orderService.createOrder(dto);

  // L'entità di dominio viene trasformata in DTO di risposta
  const responseDto = toCreateOrderResponseDto(order);
  res.status(201).json(responseDto);
}

Il controller non sa come funziona il service internamente. Il service non sa che i dati arrivano da una richiesta HTTP. L'unica cosa che li lega è la forma dei DTO che si scambiano. Se domani il service viene invocato da un worker in coda anziché da un controller HTTP, i DTO restano identici. Se il modello di dominio cambia, basta aggiornare i mapper senza toccare il controller.

Errori strutturati come DTO

Anche gli errori meritano una forma esplicita. Restituire un messaggio di errore come stringa libera è fragile e rende impossibile per il client gestire l'errore in modo programmatico. Definire DTO per gli errori porta coerenza e prevedibilità.

// DTO per un singolo errore di validazione
interface ValidationErrorDto {
  readonly field: string;
  readonly message: string;
  readonly code: string;
}

// DTO per una risposta di errore generica
interface ErrorResponseDto {
  readonly status: number;
  readonly message: string;
  readonly code: string;
  readonly timestamp: string;
  readonly errors?: readonly ValidationErrorDto[];
}

// Funzione per costruire risposte di errore coerenti
function toValidationErrorResponse(
  issues: { path: (string | number)[]; message: string }[]
): ErrorResponseDto {
  return {
    status: 400,
    message: "I dati inviati non sono validi",
    code: "VALIDATION_ERROR",
    timestamp: new Date().toISOString(),
    errors: issues.map((issue) => ({
      field: issue.path.join("."),
      message: issue.message,
      code: "INVALID_FIELD",
    })),
  };
}

Ogni errore ha un codice macchina (code) e un messaggio umano (message). Il client può fare switch sul codice per decidere come reagire, e mostrare il messaggio all'utente. I campi sono readonly, il timestamp è generato al momento della creazione, la struttura è sempre la stessa indipendentemente dal tipo di errore. Prevedibilità.

Conclusioni

Il DTO non è un pattern complesso, ma è un pattern esigente. Chiede disciplina nella separazione tra ciò che è interno e ciò che è esterno, tra ciò che ha comportamento e ciò che è puro dato, tra ciò che è una forma a compile-time e ciò che è una garanzia a runtime. TypeScript fornisce gli strumenti per esprimere tutto questo con una precisione che pochi altri linguaggi offrono nel medesimo ecosistema: tipi strutturali, generics, utility types, discriminated unions, branded types.

La regola fondamentale è semplice: un DTO è un contratto. Non è un modello di dominio con i metodi rimossi. Non è un'interfaccia buttata lì per far contento il compilatore. È la descrizione esatta di ciò che attraversa un confine, validata dove serve, immutabile per scelta, trasformata con funzioni pure. Quando i DTO sono fatti bene, il resto dell'architettura ne beneficia in modo sproporzionato: i layer si disaccoppiano, gli errori emergono prima, il codice diventa navigabile, e le API diventano un piacere da consumare.