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.”