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
This commit is contained in:
Jay-Chou
2026-02-07 02:03:44 +08:00
parent e15b3d79ca
commit cbdc24e91f
2 changed files with 126 additions and 0 deletions

View File

@@ -20,6 +20,11 @@ import {
import { loadPackage } from '../utils/load-package.util.js';
import { isNil, isUndefined } from '../utils/shared.utils.js';
/**
* @publicApi
*/
export type ValidationErrorFormat = 'list' | 'grouped';
/**
* @publicApi
*/
@@ -33,6 +38,18 @@ export interface ValidationPipeOptions extends ValidatorOptions {
expectedType?: Type<any>;
validatorPackage?: ValidatorPackage;
transformerPackage?: TransformerPackage;
/**
* Specifies the format of validation error messages.
* - 'list': Returns an array of error message strings (default). The response message is `string[]`.
* - 'grouped': Returns an object with property paths as keys and arrays of unmodified error messages as values.
* The response message is `Record<string, string[]>`. Custom messages defined in validation decorators
* (e.g., `@IsNotEmpty({ message: 'Name is required' })`) are preserved without parent path prefixes.
*
* @remarks
* When using 'grouped', the `message` property in the error response changes from `string[]` to `Record<string, string[]>`.
* If you have exception filters or interceptors that assume `message` is always an array, they will need to be updated.
*/
errorFormat?: ValidationErrorFormat;
}
let classValidator: any = {} as any;
@@ -59,6 +76,7 @@ export class ValidationPipe implements PipeTransform<any> {
protected expectedType: Type<any> | undefined;
protected exceptionFactory: (errors: ValidationError[]) => any;
protected validateCustomDecorators: boolean;
protected errorFormat: ValidationErrorFormat;
constructor(@Optional() options?: ValidationPipeOptions) {
options = options || {};
@@ -69,6 +87,7 @@ export class ValidationPipe implements PipeTransform<any> {
expectedType,
transformOptions,
validateCustomDecorators,
errorFormat,
...validatorOptions
} = options;
@@ -81,6 +100,7 @@ export class ValidationPipe implements PipeTransform<any> {
this.validateCustomDecorators = validateCustomDecorators || false;
this.errorHttpStatusCode = errorHttpStatusCode || HttpStatus.BAD_REQUEST;
this.expectedType = expectedType;
this.errorFormat = errorFormat || 'list';
this.exceptionFactory =
options.exceptionFactory || this.createExceptionFactory();
@@ -191,6 +211,12 @@ export class ValidationPipe implements PipeTransform<any> {
if (this.isDetailedOutputDisabled) {
return new HttpErrorByCode[this.errorHttpStatusCode]();
}
if (this.errorFormat === 'grouped') {
const errors = this.groupValidationErrors(validationErrors);
return new HttpErrorByCode[this.errorHttpStatusCode]({
message: errors,
});
}
const errors = this.flattenValidationErrors(validationErrors);
return new HttpErrorByCode[this.errorHttpStatusCode](errors);
};
@@ -318,6 +344,25 @@ export class ValidationPipe implements PipeTransform<any> {
.toArray();
}
protected groupValidationErrors(
validationErrors: ValidationError[],
parentPath?: string,
): Record<string, string[]> {
const result: Record<string, string[]> = {};
for (const error of validationErrors) {
const path = parentPath
? `${parentPath}.${error.property}`
: error.property;
if (error.constraints) {
result[path] = Object.values(error.constraints);
}
if (error.children && error.children.length) {
Object.assign(result, this.groupValidationErrors(error.children, path));
}
}
return result;
}
protected mapChildrenToValidationErrors(
error: ValidationError,
parentPath?: string,

View File

@@ -3,6 +3,7 @@ import {
IsArray,
IsBoolean,
IsDefined,
IsNotEmpty,
IsOptional,
IsString,
ValidateNested,
@@ -181,6 +182,86 @@ describe('ValidationPipe', () => {
]);
}
});
describe('when errorFormat is "grouped"', () => {
beforeEach(() => {
target = new ValidationPipe({ errorFormat: 'grouped' });
});
it('should return grouped errors with property paths as keys', async () => {
try {
const model = new TestModelWithNested();
model.test = new TestModel2();
await target.transform(model, {
type: 'body',
metatype: TestModelWithNested,
});
} catch (err) {
expect(err.getResponse().message).to.be.eql({
prop: ['prop must be a string'],
'test.prop1': ['prop1 must be a string'],
'test.prop2': ['prop2 must be a boolean value'],
});
}
});
it('should return grouped errors for nested arrays', async () => {
try {
const model = new TestModelForNestedArrayValidation();
model.test = [new TestModel2()];
await target.transform(model, {
type: 'body',
metatype: TestModelForNestedArrayValidation,
});
} catch (err) {
expect(err.getResponse().message).to.be.eql({
prop: ['prop must be a string'],
'test.0.prop1': ['prop1 must be a string'],
'test.0.prop2': ['prop2 must be a boolean value'],
});
}
});
class NestedChildWithCustomMessage {
@IsNotEmpty({ message: 'Name is required' })
@IsString({ message: 'Name must be a string' })
name: string;
}
class ParentWithCustomMessage {
@IsString()
title: string;
@IsDefined()
@Type(() => NestedChildWithCustomMessage)
@ValidateNested()
child: NestedChildWithCustomMessage;
}
it('should preserve custom validation messages without prepending parent path', async () => {
try {
const model = new ParentWithCustomMessage();
model.child = new NestedChildWithCustomMessage();
await target.transform(model, {
type: 'body',
metatype: ParentWithCustomMessage,
});
} catch (err) {
const message = err.getResponse().message;
expect(message.title).to.be.eql(['title must be a string']);
// Custom messages should be preserved without 'child.' prefix in the message itself
expect(message['child.name']).to.have.members([
'Name is required',
'Name must be a string',
]);
// Verify custom messages don't have parent path prepended
expect(message['child.name']).to.not.include.members([
'child.Name is required',
'child.Name must be a string',
]);
}
});
});
});
describe('when validation transforms', () => {
it('should return a TestModel instance', async () => {