Skip to content

Commit c5f40d7

Browse files
committed
feat(common): add error format option to validation pipe
Add a new `errorFormat` option to `ValidationPipeOptions` that allows users to choose between two validation error formats: - 'list' (default): Returns an array of error message strings with parent path prepended to messages (current behavior) - 'grouped': Returns an object with property paths as keys and arrays of unmodified constraint messages as values The 'grouped' format separates property paths from error messages, which prevents custom validation messages from being modified with parent path prefixes. Closes #16268
1 parent 782e071 commit c5f40d7

File tree

2 files changed

+126
-0
lines changed

2 files changed

+126
-0
lines changed

packages/common/pipes/validation.pipe.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ import {
2020
import { loadPackage } from '../utils/load-package.util';
2121
import { isNil, isUndefined } from '../utils/shared.utils';
2222

23+
/**
24+
* @publicApi
25+
*/
26+
export type ValidationErrorFormat = 'list' | 'grouped';
27+
2328
/**
2429
* @publicApi
2530
*/
@@ -33,6 +38,18 @@ export interface ValidationPipeOptions extends ValidatorOptions {
3338
expectedType?: Type<any>;
3439
validatorPackage?: ValidatorPackage;
3540
transformerPackage?: TransformerPackage;
41+
/**
42+
* Specifies the format of validation error messages.
43+
* - 'list': Returns an array of error message strings (default). The response message is `string[]`.
44+
* - 'grouped': Returns an object with property paths as keys and arrays of unmodified error messages as values.
45+
* The response message is `Record<string, string[]>`. Custom messages defined in validation decorators
46+
* (e.g., `@IsNotEmpty({ message: 'Name is required' })`) are preserved without parent path prefixes.
47+
*
48+
* @remarks
49+
* When using 'grouped', the `message` property in the error response changes from `string[]` to `Record<string, string[]>`.
50+
* If you have exception filters or interceptors that assume `message` is always an array, they will need to be updated.
51+
*/
52+
errorFormat?: ValidationErrorFormat;
3653
}
3754

3855
let classValidator: ValidatorPackage = {} as any;
@@ -59,6 +76,7 @@ export class ValidationPipe implements PipeTransform<any> {
5976
protected expectedType: Type<any> | undefined;
6077
protected exceptionFactory: (errors: ValidationError[]) => any;
6178
protected validateCustomDecorators: boolean;
79+
protected errorFormat: ValidationErrorFormat;
6280

6381
constructor(@Optional() options?: ValidationPipeOptions) {
6482
options = options || {};
@@ -69,6 +87,7 @@ export class ValidationPipe implements PipeTransform<any> {
6987
expectedType,
7088
transformOptions,
7189
validateCustomDecorators,
90+
errorFormat,
7291
...validatorOptions
7392
} = options;
7493

@@ -81,6 +100,7 @@ export class ValidationPipe implements PipeTransform<any> {
81100
this.validateCustomDecorators = validateCustomDecorators || false;
82101
this.errorHttpStatusCode = errorHttpStatusCode || HttpStatus.BAD_REQUEST;
83102
this.expectedType = expectedType;
103+
this.errorFormat = errorFormat || 'list';
84104
this.exceptionFactory =
85105
options.exceptionFactory || this.createExceptionFactory();
86106

@@ -183,6 +203,12 @@ export class ValidationPipe implements PipeTransform<any> {
183203
if (this.isDetailedOutputDisabled) {
184204
return new HttpErrorByCode[this.errorHttpStatusCode]();
185205
}
206+
if (this.errorFormat === 'grouped') {
207+
const errors = this.groupValidationErrors(validationErrors);
208+
return new HttpErrorByCode[this.errorHttpStatusCode]({
209+
message: errors,
210+
});
211+
}
186212
const errors = this.flattenValidationErrors(validationErrors);
187213
return new HttpErrorByCode[this.errorHttpStatusCode](errors);
188214
};
@@ -310,6 +336,25 @@ export class ValidationPipe implements PipeTransform<any> {
310336
.toArray();
311337
}
312338

339+
protected groupValidationErrors(
340+
validationErrors: ValidationError[],
341+
parentPath?: string,
342+
): Record<string, string[]> {
343+
const result: Record<string, string[]> = {};
344+
for (const error of validationErrors) {
345+
const path = parentPath
346+
? `${parentPath}.${error.property}`
347+
: error.property;
348+
if (error.constraints) {
349+
result[path] = Object.values(error.constraints);
350+
}
351+
if (error.children && error.children.length) {
352+
Object.assign(result, this.groupValidationErrors(error.children, path));
353+
}
354+
}
355+
return result;
356+
}
357+
313358
protected mapChildrenToValidationErrors(
314359
error: ValidationError,
315360
parentPath?: string,

packages/common/test/pipes/validation.pipe.spec.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
IsArray,
77
IsBoolean,
88
IsDefined,
9+
IsNotEmpty,
910
IsOptional,
1011
IsString,
1112
ValidateNested,
@@ -183,6 +184,86 @@ describe('ValidationPipe', () => {
183184
]);
184185
}
185186
});
187+
188+
describe('when errorFormat is "grouped"', () => {
189+
beforeEach(() => {
190+
target = new ValidationPipe({ errorFormat: 'grouped' });
191+
});
192+
193+
it('should return grouped errors with property paths as keys', async () => {
194+
try {
195+
const model = new TestModelWithNested();
196+
model.test = new TestModel2();
197+
await target.transform(model, {
198+
type: 'body',
199+
metatype: TestModelWithNested,
200+
});
201+
} catch (err) {
202+
expect(err.getResponse().message).to.be.eql({
203+
prop: ['prop must be a string'],
204+
'test.prop1': ['prop1 must be a string'],
205+
'test.prop2': ['prop2 must be a boolean value'],
206+
});
207+
}
208+
});
209+
210+
it('should return grouped errors for nested arrays', async () => {
211+
try {
212+
const model = new TestModelForNestedArrayValidation();
213+
model.test = [new TestModel2()];
214+
await target.transform(model, {
215+
type: 'body',
216+
metatype: TestModelForNestedArrayValidation,
217+
});
218+
} catch (err) {
219+
expect(err.getResponse().message).to.be.eql({
220+
prop: ['prop must be a string'],
221+
'test.0.prop1': ['prop1 must be a string'],
222+
'test.0.prop2': ['prop2 must be a boolean value'],
223+
});
224+
}
225+
});
226+
227+
class NestedChildWithCustomMessage {
228+
@IsNotEmpty({ message: 'Name is required' })
229+
@IsString({ message: 'Name must be a string' })
230+
name: string;
231+
}
232+
233+
class ParentWithCustomMessage {
234+
@IsString()
235+
title: string;
236+
237+
@IsDefined()
238+
@Type(() => NestedChildWithCustomMessage)
239+
@ValidateNested()
240+
child: NestedChildWithCustomMessage;
241+
}
242+
243+
it('should preserve custom validation messages without prepending parent path', async () => {
244+
try {
245+
const model = new ParentWithCustomMessage();
246+
model.child = new NestedChildWithCustomMessage();
247+
await target.transform(model, {
248+
type: 'body',
249+
metatype: ParentWithCustomMessage,
250+
});
251+
} catch (err) {
252+
const message = err.getResponse().message;
253+
expect(message.title).to.be.eql(['title must be a string']);
254+
// Custom messages should be preserved without 'child.' prefix in the message itself
255+
expect(message['child.name']).to.have.members([
256+
'Name is required',
257+
'Name must be a string',
258+
]);
259+
// Verify custom messages don't have parent path prepended
260+
expect(message['child.name']).to.not.include.members([
261+
'child.Name is required',
262+
'child.Name must be a string',
263+
]);
264+
}
265+
});
266+
});
186267
});
187268
describe('when validation transforms', () => {
188269
it('should return a TestModel instance', async () => {

0 commit comments

Comments
 (0)