Destino Dev

Blog

DTOs type-safe em TypeScript: decorators, generics e type guards passo a passo

Por John Pires · 20 de março de 2026

DTOs type-safe em TypeScript: decorators, generics e type guards passo a passo

Por que aprender isso em 2026

TypeScript evoluiu para além de tipagem estática no editor: em 2026, aplicações modernas exigem translado seguro entre JSON não tipado (requests, bancos, APIs externas) e objetos fortemente tipados. DTOs (Data Transfer Objects) são a fronteira entre o mundo dinâmico e o modelo tipado da sua aplicação. Aprender a construir DTOs type-safe com decorators, generics e type guards reduz bugs, melhora autocompletar e torna validações consistentes.

Para iniciantes, a ideia central é simples: você descreve a forma dos dados em classes / tipos e cria código que verifica em tempo de execução se um objeto JSON respeita essa forma. Isso evita erros comuns como "campo ausente" ou "campo com tipo incorreto" antes que o erro chegue às camadas internas.

Em 2026, há duas razões práticas para adotar essa abordagem sem ferramentas pesadas: (1) ganho de produtividade com editor e intellisense; (2) menor dependência de bibliotecas externas quando você precisa de soluções sob medida. Vamos construir uma solução incremental que usa decorators para marcar campos, generics para mapear tipos, e type guards para validação.

Contexto técnico

Usaremos recursos do TypeScript atuais: decorators experimentais (com reflect-metadata quando necessário), generics, e funções de type guard. Nada de frameworks enormes — foco em APIs leves que você integra ao Express, Fastify, ou serverless.

O que você sairá sabendo

  • Como anotar propriedades com decorators para construir metadados de DTOs.
  • Como criar type guards reutilizáveis para validar primitivas e DTOs compostos.
  • Como usar generics para mapear JSON para instâncias tipadas.
  • Como integrar validação em um handler simples e tratar erros comuns.

Configuração do ambiente

Vamos montar um ambiente mínimo. Você precisa de Node.js (>=16 recomendado), npm/yarn, e TypeScript. Em projetos reais, adicione bundler/transpiler conforme necessário. Aqui usaremos ts-node para executar exemplos rápidos.

Passos rápidos

Crie uma pasta e inicialize:

mkdir dtos-demo
cd dtos-demo
npm init -y
npm install typescript ts-node reflect-metadata --save-dev
npx tsc --init

Explicação: o reflect-metadata é útil quando você quer capturar tipos em runtime via Reflect, e ts-node facilita rodar TypeScript sem build. Vamos ativar opções no tsconfig:

// tsconfig.json (trechos importantes)
{
  "compilerOptions": {
    "target": "es2020",
    "module": "commonjs",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "strict": true,
    "esModuleInterop": true
  }
}

Depois, crie um arquivo src/index.ts e importe reflect-metadata no topo:

// src/index.ts
import "reflect-metadata";

// seu código aqui
console.log('ambiente pronto');

Explicação: Reflect-metadata permite que decorators gravem e leiam metadados de tipos em tempo de execução. Não é mágico: o compilador precisa emitir metadata (emitDecoratorMetadata) para tipos básicos.

Notas de segurança e versão

Decorators ainda são considerados experimental em TS: use com cuidado e teste. Em 2026 a estabilidade é boa, mas mantenha dependencies atualizadas. Se preferir não usar reflect-metadata, é possível escrever decoradores que recebem explicitamente o tipo como string (ex: @Field('string')). Faremos ambos quando conveniente.

Hello World progressivo

Vamos construir incrementos. Primeiro, um decorator @Field que marca propriedades e registra o tipo desejado. Em seguida, um type guard para checar primitivas. Depois, uma função genérica mapToDto que cria uma instância da classe DTO com validação.

1) Decorator simples e metadados

O decorator vai anotar propriedades com o tipo pretendido. Para iniciantes, isso funciona como etiquetar caixas: você coloca um adesivo dizendo "isso deve ser string". O decorator grava o adesivo em um objeto interno.

Exemplo: decorador que armazena o tipo como string (sem reflect-metadata).

// src/decorators.ts
export const METADATA_FIELDS = Symbol('metadata_fields');

export type FieldType = 'string' | 'number' | 'boolean' | 'date' | 'object' | 'array';

export function Field(type: FieldType) {
  return function (target: any, propertyKey: string) {
    const ctor = target.constructor;
    const existing = Reflect.getOwnMetadata(METADATA_FIELDS, ctor) || {};
    existing[propertyKey] = type;
    Reflect.defineMetadata(METADATA_FIELDS, existing, ctor);
  };
}

O que faz: registra no constructor os tipos declarados. Depois podemos ler Reflect.getOwnMetadata(METADATA_FIELDS, SomeDto). Após: usaremos essa tabela para validar entradas JSON.

2) Type guards básicos

Type guards são funções que em runtime declaram para o compilador "se isso retornar true, trate como tipo X". Eles validam primitivas e estruturam checagens compostas.

// src/guards.ts
export function isString(v: any): v is string {
  return typeof v === 'string';
}
export function isNumber(v: any): v is number {
  return typeof v === 'number' && !Number.isNaN(v);
}
export function isBoolean(v: any): v is boolean {
  return typeof v === 'boolean';
}

O que faz: valida tipos primitivos. Depois usaremos essas funções dentro do mapeador genérico para garantir que o valor JSON bate com o tipo anotado.

3) Mapeador genérico: mapToDto

Agora unimos tudo: função genérica que recebe um objeto qualquer e uma classe DTO (constructor). Ela cria uma instância da classe, valida e atribui propriedades, retornando a instância ou lançando erro.

// src/mapper.ts
import { METADATA_FIELDS, FieldType } from './decorators';
import { isString, isNumber, isBoolean } from './guards';

export function mapToDto<T>(ctor: new () => T, obj: any): T {
  if (obj == null || typeof obj !== 'object') {
    throw new Error('source must be an object');
  }
  const dto = new ctor();
  const meta: Record<string, FieldType> = Reflect.getOwnMetadata(METADATA_FIELDS, ctor) || {};
  for (const key of Object.keys(meta)) {
    const expected = meta[key];
    const val = obj[key];
    if (val === undefined) {
      throw new Error(`missing required field: ${key}`);
    }
    switch (expected) {
      case 'string':
        if (!isString(val)) throw new Error(`field ${key} must be string`);
        (dto as any)[key] = val;
        break;
      case 'number':
        if (!isNumber(val)) throw new Error(`field ${key} must be number`);
        (dto as any)[key] = val;
        break;
      case 'boolean':
        if (!isBoolean(val)) throw new Error(`field ${key} must be boolean`);
        (dto as any)[key] = val;
        break;
      case 'date':
        const d = new Date(val);
        if (Number.isNaN(d.getTime())) throw new Error(`field ${key} must be valid date`);
        (dto as any)[key] = d;
        break;
      default:
        (dto as any)[key] = val; // para object/array deixamos passar (poderíamos validar recursivamente)
    }
  }
  return dto;
}

O que faz: cria a instância tipada e valida cada campo anotado. Depois: use no handler de entrada para transformar JSON em DTO.

Mini-projeto prático

Objetivo: criar um endpoint simulado que recebe um corpo JSON para criar um usuário, valida via DTO, e retorna objeto tipado. Faremos em puro Node com uma função handleRequest simulando um request/response.

1) DTO do usuário

Definimos a classe UserDto com @Field em cada propriedade. Íntegraremos nossa função mapToDto para construir a instância.

// src/user-dto.ts
import { Field } from './decorators';

export class UserDto {
  @Field('string')
  public name!: string;

  @Field('string')
  public email!: string;

  @Field('number')
  public age!: number;

  @Field('boolean')
  public active!: boolean;
}

O que faz: descreve o formato esperado do JSON para criar usuário. Depois: passamos o JSON para mapToDto(UserDto, body) para obter a instância validada.

2) Handler e execução

Vamos implementar um handler simples que recebe uma string JSON, tenta parsear e mapear para UserDto. Tratamos erros para retornar mensagens amigáveis.

// src/handler.ts
import { mapToDto } from './mapper';
import { UserDto } from './user-dto';

export function handleCreateUser(jsonBody: string) {
  try {
    const parsed = JSON.parse(jsonBody);
    const user = mapToDto(UserDto, parsed);
    // Simula salvar no banco e retornar resposta
    return { status: 201, body: { success: true, user } };
  } catch (err: any) {
    return { status: 400, body: { success: false, error: err.message } };
  }
}

// Exemplo de uso:
const good = JSON.stringify({ name: 'Ana', email: 'ana@example.com', age: 30, active: true });
console.log(handleCreateUser(good));

const bad = JSON.stringify({ name: 'Bruno', email: 'bruno@example.com', age: 'trinta', active: true });
console.log(handleCreateUser(bad));

O que faz: demonstra sucesso e erro na validação. Depois: ao rodar via ts-node, você verá o primeiro retorno com user tipado e o segundo com mensagem de erro "field age must be number".

3) Validação recursiva e arrays

Para DTOs aninhados ou arrays, expanda o decorador para aceitar uma referência de classe (constructor) em vez de apenas 'object'. Isso permite chamar mapToDto recursivamente. Exemplo simplificado abaixo.

// src/decorators-advanced.ts
export function FieldTypeOf(type: 'primitive' | 'class' | 'array', meta?: any) {
  return function (target: any, propertyKey: string) {
    const ctor = target.constructor;
    const existing = Reflect.getOwnMetadata(METADATA_FIELDS, ctor) || {};
    existing[propertyKey] = { kind: type, meta };
    Reflect.defineMetadata(METADATA_FIELDS, existing, ctor);
  };
}

O que faz: estrutura metadados que permitem saber se um campo é um DTO (class) e qual classe instanciar, ou um array de DTOs. Depois: o mapper pode chamar mapToDto recursivamente.

Erros comuns, armadilhas e dicas práticas

Essa seção reúne problemas que você encontrará ao implementar DTOs type-safe pela primeira vez. Entender essas armadilhas evita horas de debug.

1) Decorators não funcionam porque emitDecoratorMetadata está off

Se você usar reflect-metadata e esperar tipos automaticamente, o tsconfig precisa de experimentalDecorators: true e emitDecoratorMetadata: true. Sem isso, Reflect.getMetadata não trará os tipos esperados.

2) Erros confusos por diferenças entre runtime e compile-time

Tipos do TypeScript existem apenas em tempo de compilação; em runtime você manipula valores JS. Não confunda uma assinatura de função com garantia de runtime. Por isso usamos type guards para checagem.

Exemplo de armadilha: uma propriedade marcada como number pode chegar como string '123'. Sem validação, atribuiria e causaria bugs laterais.

Tip: prefira falhar rápido. Lance erros claros no mapper ao invés de tentar coerções silenciosas que mascaram dados inválidos.

3) Arrays e objetos aninhados

Validação de arrays e objetos requer recursão. Se seu decorador só registra "object", você perderá validação de forma. Registre o tipo de item do array ou a classe do objeto aninhado para validar corretamente.

4) Performance e sobrecarga

Validar tudo em cada request tem custo. Balanceie entre validar campos críticos e confiar em camadas internas. Em endpoints de alto throughput, meça e otimize: cache de metadados, validações parciais, e desativação seletiva podem reduzir latência.

Recursos para continuar estudando

Depois deste passo a passo, aprofunde em tópicos que complementam: bibliotecas de validação (zod, io-ts), padrões DTO vs Entity, e integração com frameworks web. Cada opção tem trade-offs entre ergonomia, performance e complexidade.

Bibliotecas e leituras

  • Zod: schemas polimórficos e parsing com inferência de tipo.
  • io-ts: validação funcional e codecs, integra bem com fp-ts.
  • class-transformer / class-validator: solução clássica baseada em decorators (mas com sobrecarga).
  • Documentação oficial TypeScript sobre decorators e reflect-metadata.

O que praticar a seguir

  1. Adicione validação recursiva para DTOs aninhados e arrays.
  2. Implemente mensagens de erro estruturadas (caminho do campo, tipo esperado, valor recebido).
  3. Integre com testes unitários: casos positivos e negativos para garantir regressões não aconteçam.

Próximo passo: compare sua solução caseira com zod/io-ts em um benchmark e escolha em função de ergonomia e requisitos do time.

Conclusão prática

Você aprendeu um fluxo prático para transformar JSON em DTOs type-safe usando decorators, generics e type guards. A estratégia é incremental: comece marcando campos com @Field, crie type guards para primitivas, e um mapper genérico que valida e instancia DTOs. Depois expanda para validação recursiva, arrays, e mensagens de erro claras.

A vantagem desse caminho é o controle: você sabe exatamente o que é verificado e como — sem mágica. Para iniciantes, é uma ótima forma de reforçar entendimento sobre runtime vs compile-time e ganhar confiança com TypeScript avançado.

Implementação prática: use os snippets fornecidos como base. Teste com vários payloads, escreva testes unitários e experimente integrar zod para ver as diferenças.

Resumo prático: decorators = marcação; generics = mapeamento tipado; type guards = validação runtime. Juntos, reduzem erros e tornam seu código previsível e fácil de refatorar.

Pronto para o próximo passo: transforme essa base em uma biblioteca interna do seu time e padronize DTOs em todos os serviços.

Assuntos: TypeScript, dtos, decorators, type-guards, generics

Leia também