Validando dados com class-validator e class-transformer - NestJS

Validar dados em uma API é essencial para garantir integridade, segurança e confiabilidade. Isso evita o processamento de informações inválidas ou maliciosas. Aprenda a usar class-validator e class-transformer para validar dados de forma segura em APIs desenvolvidas com NestJS.

Validando dados com class-validator e class-transformer -  NestJS
Validando dados com o class-validator e class-transformer no NestJS

Uma regra básica que todo desenvolvedor de uma API deve ter clara em sua mente é, nunca confie nos dados do frontend, nunca! Toda informação que chega na sua API deve ser sanitizada e validada, sempre!

Imagine que você está desenvolvendo uma API REST para um e-commerce. A cada requisição, você recebe dados do cliente, como nome, email e endereço. Mas e se esses dados forem inválidos, como um email sem o "@" ou um CEP com menos de 8 dígitos? É aí que a validação entra em cena.

Garantir a integridade da informação recebida, garante o correto funcionamento do código escrito. É nesse contexto que o pacote class-validator cai como uma luva, facilitando a validação dos dados.

Neste texto será abordado como utilizar o pacote class-validator em conjunto com o class-transformer para validar dados de uma forma bem simples e direta em uma API que usa o framework NestJS. Vale destacar que grande parte do conteúdo apresentado pode ser aplicado a outros projetos, mesmo que não utilizem o NestJS.

Instalando e criando o projeto com NestJS

Para criar o projeto foi utilizado o seguinte script:

# É necessário ter  Node.js instalado. Para
# este exemplo, utilizei a seguinte versão:
node --version
v22.11.0

# Instalando o CLI do NestJS
npm i -g @nestjs/cli

# Iniciando o projeto
nest new consolelog-class-validator

cd consolelog-class-validator/
npm i --save class-validator class-transformer

# Executando o projeto:
npm run start:dev

Após executar os comando acima, o projeto estará em execução. Para verificar o correto funcionamento, utilize o cURL, um navegador ou qualquer outro cliente TCP (Postman, Insomnia, etc) para consultar o endereço http://localhost:3000. A resposta esperada é Hello World:

curl localhost:3000
Hello World!%

Validando os dados que chegam na API

Iniciando pelo exemplo mais simples, considere um endpoint que receberá algumas informações de um POST. Essas informações podem ser mapeadas em uma classe e através de decorators do pacote class-validator podemos configurar a validação desejada.

import { IsBoolean, IsEmail, IsString } from "class-validator";

export class NovoUsuarioDto {
  @IsString()
  nome: string;

  @IsEmail()
  email: string;

  @IsBoolean()
  desejaReceberEmails: boolean;
}

novo-usuario.dto.ts

💡
O uso do sufixo Dto, indica Data Transfer Object. Nada mais é do que um objeto simples, sem comportamento, utilizado para transportar dados entre diferentes partes da aplicação.

Com os dados que serão recebidos no POST mapeados na classe NovoUsuarioDto, podemos indicar ao NestJS que o corpo (body) da requisição deverá ser validado. Para fazer isso, precisamos:

  • Indicar que o tipo da informação é representada pela classe NovoUsuarioDto
  • aplicar o decorator @UsePipes(new ValidationPipe())
// ...(código ocultado)...

@Controller()
export class AppController {
  // ...(código ocultado)...

  @Post()
  @UsePipes(new ValidationPipe())
  criarUsuario(@Body() body: NovoUsuarioDto) {
    return body;
  }
}

app.controller.ts

💡
Desconsidere a ausência de conformidade com as convenções de nomenclatura de endpoints neste exemplo, como no caso de POST /api/v1/usuarios.

Para iniciar os testes, basta executar o projeto com o comando npm run start:dev e então utilizar alguma ferramenta como o cURL para efetuar os testes.

Testando o cenário de sucesso

Neste teste os dados atendem aos requisitos de validação:

curl -i -X POST \
--header 'content-type: application/json' \
--data '{ "nome": "teste", "email": "[email protected]", "desejaReceberEmails": true }' \
localhost:3000

# Resultado:
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8

{"nome":"teste","email":"[email protected]","desejaReceberEmails":true}%                                

Testando o cenário de erro

Realizando outro teste, ao omitir a propriedade desejaReceberEmails, os requisitos de validação não serão atendidos e a resposta é HTTP/400 Bad Request:

curl -i -X POST \
--header 'content-type: application/json' \
--data '{ "nome": "teste", "email": "[email protected]" }' \
localhost:3000

# Resultado:

HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8

{"message":["desejaReceberEmails must be a boolean value"],"error":"Bad Request","statusCode":400}%    

Aplicando a validação para todo o projeto

Para não ser necessário utilizar o decorator @UsePipes(new ValidationPipe()) em cada método de cada controller que for receber dados, é possível configurar essa validação de forma global, como abaixo:

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe());
  await app.listen(process.env.PORT ?? 3000);
}

bootstrap();

main.ts

Removendo informações não desejadas

Um outro cenário passível de tratamento é quando a API recebe mais dados do que o necessário. No exemplo abaixo, o campoExtra é enviado no corpo da requisição, mas não está mapeado na classe NovoUsuarioDto. Nesse caso, a validação será aplicada apenas aos campos declarados na classe mencionada, enquanto os campos adicionais permanecerão intactos, exatamente como foram enviados na requisição:

curl -i -X POST \
--header 'content-type: application/json' \
--data '{ "nome": "teste", "email": "[email protected]", "desejaReceberEmails": true, "campoExtra": "extra" }' \
localhost:3000

# Resultado:
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8

{"nome":"teste","email":"[email protected]","desejaReceberEmails":true,"campoExtra":"extra"}

Uma forma simples de eliminarmos estes campos não mapeados é utilizando a opção whitelist: true:

@Post()
@UsePipes(new ValidationPipe({ whitelist: true }))
criarUsuario(@Body() body: NovoUsuarioDto) {
  return body;
}

Assim, os campos que não estiverem mapeados com decorators na classe serão ignorados. Observe abaixo que o campoExtra não foi mapeado, portanto ignorado no processo de validação e transformação:

curl -i -X POST \
--header 'content-type: application/json' \
--data '{ "nome": "teste", "email": "[email protected]", "desejaReceberEmails": true, "campoExtra": "extra" }' \
localhost:3000

# Resultado:
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8

{"nome":"teste","email":"[email protected]","desejaReceberEmails":true}

Transformando dados para o tipo correto

Outro cenário comum, é o ajuste de tipos de dados. Por exemplo, no formato JSON, datas são representadas como strings, como em 2024-12-01T12:00:00.000Z. Nesse caso, pode ser interessante converter a informação diretamente para o tipo Date e depois aplicar a validação desejada. Um exemplo similar ocorre com valores booleanos, que podem ser representados de diversas formas: "true""false""1" ou "0". Veja abaixo como esses casos podem ser tratados com o @Type e @Transform:

import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsDate } from 'class-validator';

export class ConverterDadosDto {
  @IsDate()
  @Type(() => Date)
  data: Date;

  @IsBoolean()
  @Transform(({ value }) => {
    if (typeof value === 'boolean') {
      return value;
    }

    if (value === 'true' || value === '1') {
      return true;
    }

    if (value === 'false' || value === '0') {
      return false;
    }

    return undefined;
  })
  temMaisDetalhes: boolean;
}

converter-dados.dto.ts

// ...(código ocultado)...

@Controller()
export class AppController {
  // ...(código ocultado)...

  @Post('exemplo-conversao-dados')
  @UsePipes(new ValidationPipe({ whitelist: true }))
  exemploConversaoDados(@Body() body: ConverterDadosDto) {
    console.log(
      'Data é do tipo Date:',
      body.data instanceof Date
    );

    console.log(typeof body.data);
    console.log(typeof body.temMaisDetalhes);
    return body;
  }
}

app.controller.ts

Efetuando um teste com cenário de sucesso:

curl -i -X POST \
--header 'content-type: application/json' \
--data '{ "data": "2024-12-01T12:00:00.000Z", "temMaisDetalhes": "false" }' \
localhost:3000/exemplo-conversao-dados

# Resultado
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8

{"data":"2024-12-01T12:00:00.000Z","temMaisDetalhes":false}

# No console da aplicação:
Data é do tipo Date: true
object
boolean

Testando um cenário onde data e o valor do campo temMaisDetalhes são inválidos:

curl -i -X POST \
--header 'content-type: application/json' \
--data '{ "data": "20-12-01T12:00:00.000Z", "temMaisDetalhes": "000" }' \
localhost:3000/exemplo-conversao-dados

# Resultado:
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8

{"message":["data must be a Date instance","temMaisDetalhes must be a boolean value"],"error":"Bad Request","statusCode":400}

Validando subclasses e array

Um outro caso muito comum é quando temos subconjunto de dados e/ou arrays. Por exemplo, ao considerar os dados de um cliente que pode ter múltiplos endereços, podemos usar a seguinte configuração para garantir que todos os dados sejam validados corretamente:

import { Type } from 'class-transformer';
import {
  IsArray,
  IsNumber,
  IsString,
  MinLength,
  ValidateNested,
} from 'class-validator';

export class Endereco {
  @IsString()
  @MinLength(3)
  logradouro: string;

  @IsNumber()
  numero: number;
}

export class Cliente {
  @IsString()
  nome: string;

  @IsArray()
  @ValidateNested({ each: true })
  @Type(() => Endereco)
  enderecos: Endereco[];
}

// ...app.controller.ts (trecho)

export class AppController {
  @Post('exemplo-subclasses')
  @UsePipes(new ValidationPipe({ whitelist: true }))
  exemploSubclasses(@Body() body: Cliente) {
    return body;
  }
}

Testando um resultado de sucesso:

curl -i -X POST \
--header 'content-type: application/json' \
--data '{ "nome": "Cliente 123", "enderecos": [{ "logradouro": "aaaaa", "numero": 123 }, { "logradouro": "bbbb", "numero": 444 }] }' \
localhost:3000/exemplo-subclasses

# Resultado:
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8

{"nome":"Cliente 123","enderecos":[{"logradouro":"aaaaa","numero":123},{"logradouro":"bbbb","numero":444}]}%       

Testando um cenário com erro:

curl -i -X POST \
--header 'content-type: application/json' \
--data '{ "nome": "Cliente 123", "enderecos": [{ "logradouro": "", "numero": 123 }, { "logradouro": "bbbb", "numero": "a" }] }' \
localhost:3000/exemplo-subclasses

# Resultado:
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8

{"message":["enderecos.0.logradouro must be longer than or equal to 3 characters","enderecos.1.numero must be a number conforming to the specified constraints"],"error":"Bad Request","statusCode":400}%      

Como validar diferentes classes dependendo de uma informação específica

Em outros cenários, pode ser necessário validar mais de um modelo. Por exemplo, imagine que a API foi projetada para receber dados de diferentes tipos de veículos. Dependendo do tipo de veículo (caminhão, carro ou moto), será preciso validar propriedades específicas para cada caso. Dessa forma, é possível mapear as particularidades de cada tipo de veículo em uma classe específica, com suas próprias regras de validação, e utilizar outra classe como um container. Essa classe (container) conteria um campo responsável por identificar o tipo de veículo, permitindo a seleção e aplicação da classe adequada para realizar o mapeamento, veja abaixo:

import { Type } from 'class-transformer';
import {
  IsBoolean,
  IsEnum,
  IsNotEmptyObject,
  IsNumber,
  ValidateNested,
} from 'class-validator';

export class CaminhaoDto {
  @IsNumber()
  cargaMaxima: number;
}

export class CarroDto {
  @IsNumber()
  quantidadeMaxPassageiros: number;
}

export class MotoDto {
  @IsBoolean()
  temCompartimentoEmbaixoDoBanco: boolean;
}

export enum VeiculoTipo {
  carro = 'carro',
  moto = 'moto',
  caminhao = 'caminhao',
}

export class VeiculoDto {
  @IsNumber()
  potencia: number;

  @IsEnum(VeiculoTipo)
  tipo: string;

  @IsNotEmptyObject()
  @ValidateNested()
  @Type((opts) => {
    switch (opts.object.tipo) {
      case VeiculoTipo.caminhao:
        return CaminhaoDto;
      case VeiculoTipo.carro:
        return CarroDto;
      case VeiculoTipo.moto:
      default:
        return MotoDto;
    }
  })
  detalhes: MotoDto | CarroDto | CaminhaoDto;
}

Observe na configuração acima que, dependendo do valor do valor do campo tipo, a propriedade detalhes é associada a uma classe específica. Assim, é possível enviar o seguinte na requisição:

curl -i -X POST \
--header 'content-type: application/json' \
--data '{ "tipo": "carro", "potencia": 100, "detalhes": { "quantidadeMaxPassageiros": 5 } }' \
localhost:3000/exemplo-classes-validacao

# Resultado:
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8

{"tipo":"carro","potencia":100,"detalhes":{"quantidadeMaxPassageiros":5}}   

# ######

curl -i -X POST \
--header 'content-type: application/json' \
--data '{ "tipo": "moto", "potencia": 100, "detalhes": { "temCompartimentoEmbaixoDoBanco": true } }' \
localhost:3000/exemplo-classes-validacao

# Resultado:
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8

{"tipo":"moto","potencia":100,"detalhes":{"temCompartimentoEmbaixoDoBanco":true}}

# ######

curl -i -X POST \
--header 'content-type: application/json' \
--data '{ "tipo": "caminhao", "potencia": 100, "detalhes": { "temCompartimentoEmbaixoDoBanco": true } }' \
localhost:3000/exemplo-classes-validacao

# Resultado:
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8

{"message":["detalhes.cargaMaxima must be a number conforming to the specified constraints"],"error":"Bad Request","statusCode":400}

Teste unitários com Jest

A criação de testes unitários para verificar se as configurações de validação estão corretas é relativamente simples. Veja os exemplos a seguir:

💡
Recomendo fortemente o uso de testes unitários em classes que utilizam os decorators das bibliotecas class-validator e class-transformer. Durante o desenvolvimento, frequentemente identifiquei erros em cenários não previstos inicialmente, graças aos testes unitários. Por isso, para garantir um código mais consistente, minha recomendação é sempre implementar testes unitários específicos para essas classes.
// *****************************
# Classe que está sendo testada:
// *****************************
import { IsBoolean, IsEmail, IsString } from "class-validator";

export class NovoUsuarioDto {
  @IsString()
  nome: string;

  @IsEmail()
  email: string;

  @IsBoolean()
  desejaReceberEmails: boolean;
}

// **************************
// Testes unitários com Jest:
// **************************
import { plainToClass } from 'class-transformer';
import { NovoUsuarioDto } from './novo-usuario.dto';
import { validateSync } from 'class-validator';

describe('NovoUsuarioDto', () => {
  it(`deve lançar erro quando o campo "nome"
      não for informado`, () => {
    const dadosPostados = {
      email: '[email protected]',
      desejaReceberEmails: true,
    };

    const dto = plainToClass(NovoUsuarioDto, dadosPostados);
    const errors = validateSync(dto);

    expect(errors).toHaveLength(1);
  });

  it(`deve lançar erro quando o campo "desejaReceberEmails"
      não for informado`, () => {
    const dadosPostados = {
      nome: 'Teste',
      email: '[email protected]',
    };

    const dto = plainToClass(NovoUsuarioDto, dadosPostados);
    const errors = validateSync(dto);

    expect(errors).toHaveLength(1);
  });

  it(`deve lançar erro quando o campo "desejaReceberEmails"
    não for boolean`, () => {
    const dadosPostados = {
      nome: 'teste',
      email: '[email protected]',
      desejaReceberEmails: 'true',
    };

    const dto = plainToClass(NovoUsuarioDto, dadosPostados);
    const errors = validateSync(dto);

    expect(errors).toHaveLength(1);
    expect(
      errors.some(
        (a) => a.property === 'desejaReceberEmails',
      ),
    ).toBeTruthy();
  });

  it(`deve lançar 3 erros quando nenhum campo
      obrigatório for informado`, () => {
    const dadosPostados = {};

    const dto = plainToClass(NovoUsuarioDto, dadosPostados);
    const errors = validateSync(dto);

    expect(errors).toHaveLength(3);

    const nomeDosCamposQueDeramErro = errors.map(
      (a) => a.property,
    );
    expect(nomeDosCamposQueDeramErro).toContain('nome');
    expect(nomeDosCamposQueDeramErro).toContain('email');
    expect(nomeDosCamposQueDeramErro).toContain(
      'desejaReceberEmails',
    );
  });

  it(`deve remover campos não mapeados quando
      whitelist for true`, () => {
    const dadosPostados = {
      nome: 'teste',
      email: '[email protected]',
      desejaReceberEmails: true,
      campoExtra1: 'teste1',
      campoExtra2: 'teste2',
    };

    const dto = plainToClass(NovoUsuarioDto, dadosPostados);
    const errors = validateSync(dto, { whitelist: true });

    expect(errors).toHaveLength(0);

    expect(dto).toHaveProperty('nome');
    expect(dto).toHaveProperty('email');
    expect(dto).toHaveProperty('desejaReceberEmails');

    expect(dto).not.toHaveProperty('campoExtra1');
    expect(dto).not.toHaveProperty('campoExtra2');
  });
});
💡
Dica: Em projetos NestJS, utilize o comando npm run test:watch enquanto escreve os testes unitários. Este comando inicia um processo de execução de testes que monitora continuamente as alterações no código e executa os testes automaticamente sempre que uma modificação é detectada.

Considerações

Neste texto, abordamos exemplos básicos envolvendo validação de dados com os pacotes class-validator e class-transformer. Apesar de o projeto de estudo utilizar NestJS, boa parte do conteúdo pode ser reaproveitada em outros projetos que não utilizem necessariamente NestJS. Para aprofundar os estudos, sugiro dar uma olhada na documentação oficial: