n4nAI

Validate chat completion requests with NestJS DTOs

Learn how to implement nestjs dto chat completion validation for LLM APIs using class-validator and NestJS pipes, with step-by-step code and verification.

n4n Team2 min read449 words

Audio narration

Coming soon — every post will get a voice note here.

Building a chat completion endpoint in NestJS without input validation is a liability. This guide shows how to implement nestjs dto chat completion validation with class-validator so malformed requests die at the edge instead of burning tokens upstream.

Step 1: Scaffold the project and install dependencies

Create a fresh NestJS app and add the validation libraries. You need class-validator for the decorators and class-transformer because NestJS uses it to convert plain objects into class instances.

npm i -g @nestjs/cli
nest new chat-api --strict
cd chat-api
npm i class-validator class-transformer

Keep --strict on. It forces explicit types and surfaces DTO mismatches at compile time.

Step 2: Model the OpenAI-compatible request shape

A chat completion call is not just a prompt string. It is a structured payload with a model identifier, a list of messages, and sampling parameters. Define two DTOs: one for a single message and one for the request.

// src/chat/dto/message.dto.ts
export class MessageDto {
  role: string;
  content: string;
}

// src/chat/dto/chat-completion.dto.ts
import { Type } from 'class-transformer';
import { ValidateNested, IsArray, IsString } from 'class-validator';
import { MessageDto } from './message.dto';

export class ChatCompletionDto {
  @IsString()
  model: string;

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

This is the minimal contract. Anything beyond model and messages is ignored until we tighten the pipe later.

Step 3: Add precise validation rules

Loosely typed DTOs let garbage through. Lock down roles, constrain numeric ranges, and forbid unknown fields. The LLM providers will reject some of this anyway, but you should reject it faster and with better errors.

// src/chat/dto/message.dto.ts
import { IsEnum, IsString, MaxLength } from 'class-validator';

export enum ChatRole {
  System = 'system',
  User = 'user',
  Assistant = 'assistant',
}

export class MessageDto {
  @IsEnum(ChatRole)
  role: ChatRole;

  @IsString()
  @MaxLength(32000)
  content: string;
}
// src/chat/dto/chat-completion.dto.ts
import { Type } from 'class-transformer';
import {
  ValidateNested,
  IsArray,
  IsString,
  IsOptional,
  IsFloat,
  Min,
  Max,
  IsInt,
} from 'class-validator';
import { MessageDto } from './message.dto';

export class ChatCompletionDto {
  @IsString()
  @MaxLength(128)
  model: string;

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

  @IsOptional()
  @IsFloat()
  @Min(0)
  @Max(2)
  temperature?: number;

  @IsOptional()
  @IsInt()
  @Min(1)
  @Max(8192)
  max_tokens?: number;
}

The MaxLength on model stops someone from sending a 5 MB string as a model name. The temperature range matches what most OpenAI-compatible servers accept. If you need n or stop, add them with equal discipline.

Step 4: Enable a global ValidationPipe

Without the pipe, decorators do nothing. Configure it in main.ts with whitelist and forbidNonWhitelisted so extra keys are rejected, not silently stripped.

// src/main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
      transformOptions: { enableImplicitConversion: true },
    }),
  );
  await app.listen(3000);
}
bootstrap();

transform: true is what makes @Type(() => MessageDto) actually instantiate the nested class. Skip it and your nested validation never runs.

Step 5: Write the controller and service

The controller accepts the DTO and hands it to a service. The service forwards to an LLM endpoint. Routing through a gateway like n4n.ai gives you one OpenAI-compatible endpoint and automatic fallback when a provider is degraded, but it does not absolve you from validating inbound shape.

// src/chat/chat.controller.ts
import { Body, Controller, Post } from '@nestjs/common';
import { ChatCompletionDto } from './dto/chat-completion.dto';
import { ChatService } from './chat.service';

@Controller('v1')
export class ChatController {
  constructor(private readonly chat: ChatService) {}

  @Post('chat/completions')
  complete(@Body() dto: ChatCompletionDto) {
    return this.chat.complete(dto);
  }
}
// src/chat/chat.service.ts
import { Injectable } from '@nestjs/common';
import { ChatCompletionDto } from './dto/chat-completion.dto';

@Injectable()
export class ChatService {
  async complete(dto: ChatCompletionDto) {
    const res = await fetch(process.env.LLM_ENDPOINT!, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${process.env.LLM_API_KEY}`,
      },
      body: JSON.stringify(dto),
    });
    if (!res.ok) throw new Error(`Upstream error: ${res.status}`);
    return res.json();
  }
}

Register ChatController and ChatService in a ChatModule. At this point, any request failing the DTO rules returns 400 Bad Request with a structured error before the fetch fires.

Step 6: Extend validation for provider-specific fields

Real integrations need more than temperature. Add stop sequences and presence_penalty with the same rigor.

// inside ChatCompletionDto
@IsOptional()
@IsArray()
@IsString({ each: true })
@MaxLength(16, { each: true })
stop?: string[];

@IsOptional()
@IsFloat()
@Min(-2)
@Max(2)
presence_penalty?: number;

If you forward cache-control hints, validate them too. Providers ignore unknown keys, but your contract should not.

Step 7: Verify the validation works

Start the app and fire two curls: one valid, one missing model.

curl -X POST localhost:3000/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}'

Expected: 200 with a completion JSON.

curl -X POST localhost:3000/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{"messages":[{"role":"user","content":"hi"}]}'

Expected: 400 with model should not be empty and model must be a string.

For automated confidence, add a unit test with supertest:

// src/chat/chat.controller.spec.ts
import { Test } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { ChatModule } from './chat.module';

it('rejects missing model', async () => {
  const moduleRef = await Test.createTestingModule({
    imports: [ChatModule],
  }).compile();
  const app: INestApplication = moduleRef.createNestApplication();
  await app.init();
  await request(app.getHttpServer())
    .post('/v1/chat/completions')
    .send({ messages: [{ role: 'user', content: 'hi' }] })
    .expect(400);
});

If the test goes green and the valid curl returns a completion, your nestjs dto chat completion validation is enforced end to end. Tighten the DTOs as your product surface grows; never loosen them to “make the client happy.”

Tagsnestjsclass-validatorvalidationtypescript

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All nestjs llm api integration posts →