NestJS — Validate requests with custom decorators

It is a must to add validation to all your APIs to avoid unexpected usage. The official document of NestJS suggests to validate requests with the Validation Pipe. Yet, the drawback is you will have to create a DTO for each API. If you don’t like the official approach, you may try my custom decorator approach. Imagine something like this:

@Post('create-proposal')
@Validate()
createProposal(
@Body('title') @Required('string', { max: 36 }) title: string,
@Body('type') @Required(ProposalType) type: ProposalType,
@Body('price') @Required('number', { min: 0 }) price: number,
@Body('remark') @Optional('string', { max: 2000 }) remark: string
) {
...
}

This approach is much simpler and more convenient when you are working on a small scale application.

For simplicity, this article will only cover the basic use cases, which are enum validation and some of the primitive type validations.

Introduction & Setup

We are going to define two type of decorators. First, some parameter level decorators for us to add metadata to our parameters. In simple, to tell the system which parameter to validate and how it should be validated. Secondly, we will define a method level decorator which holds all the validation logic. It will read all the metadata in run-time and run the validation based on the metadata.

First things first, we need to install the reflect-metadata package which helps us to declare and read metadata.

npm install reflect-metadata

Define parameter decorators

const requiredMetadataKey = Symbol('required');
const optionalMetadataKey = Symbol('optional');
export function Optional(type?: string | object, config?: {
max?: number,
min?: number
}) {
return function (
target: object,
propertyKey: string | symbol,
parameterIndex: number
) {
let existingOptionalParameters = Reflect.getOwnMetadata(optionalMetadataKey, target, propertyKey) || [];
existingOptionalParameters.push({
index: parameterIndex,
type: type,
config: config || {}
});
Reflect.defineMetadata(optionalMetadataKey, existingOptionalParameters, target, propertyKey);
}
}
export function Required(type?: string | object, config?: {
max?: number,
min?: number
}) {
return function (
target: object,
propertyKey: string | symbol,
parameterIndex: number
) {
let existingRequiredParameters = Reflect.getOwnMetadata(requiredMetadataKey, target, propertyKey) || [];
existingRequiredParameters.push({
index: parameterIndex,
type: type,
config: config || {}
});
Reflect.defineMetadata(requiredMetadataKey, existingRequiredParameters, target, propertyKey);
}
}

Here are examples to define two parameter decorators, @Required() and @Optional() . @Required() declares that the parameter is mandatory, while @Optional() for nullable. We can further state the data type and max/min value within the decorators. You may extend the config parameter to suit more cases, like isEmail, etc. In case you want to combine two decorators into one, you may also add isNullable in your config. One thing to keep in mind is parameter decorator should not include any validation logic. It is only used to describe your parameter. All your validations are done in next section.

Define method decorators

import 'reflect-metadata';
import { HttpException } from '@nestjs/common/exceptions';
export function Validate() {
return function(target: any, propertyName: string, descriptor: TypedPropertyDescriptor<Function>) {
let method = descriptor.value;
descriptor.value = function () {
let validate = (isOptional, parameter) => {
let action = (value) => {
//*** Your validation start here
if (typeof parameter.type === 'string') {
if (typeof value !== parameter.type) {
throw new HttpException({
statusCode: 422,
error: 'Unprocessable Entity',
message: 'One/some parameter(s) is/are with incorrect type (index code: ' + parameter.index + ', expected type: ' + parameter.type + ', received: ' + typeof value + ')'
}, 422);
}
switch(parameter.type) {
case 'number': {
if ((!isOptional || value != null) && parameter.config.max != null && value > parameter.config.max) {
throw new HttpException({
statusCode: 422,
error: 'Unprocessable Entity',
message: 'One/some parameter(s) is/are too large (index code: ' + parameter.index + ', maximun: ' + parameter.config.max + ', received: ' + value + ')'
}, 422);
}
if ((!isOptional || value != null) && parameter.config.min != null && value < parameter.config.min) {
throw new HttpException({
statusCode: 422,
error: 'Unprocessable Entity',
message: 'One/some parameter(s) is/are too small (index code: ' + parameter.index + ', minimum: ' + parameter.config.min + ', received: ' + value + ')'
}, 422);
}
break;
}
case 'string': {
let length = value ? value.length : 0;
if ((!isOptional || value != null) && parameter.config.max != null && length > parameter.config.max) {
throw new HttpException({
statusCode: 422,
error: 'Unprocessable Entity',
message: 'One/some parameter(s) is/are too long (index code: ' + parameter.index + ', maximun: ' + parameter.config.max + ', received length: ' + length + ')'
}, 422);
}
if ((!isOptional || value != null) && parameter.config.min != null && length < parameter.config.min) {
throw new HttpException({
statusCode: 422,
error: 'Unprocessable Entity',
message: 'One/some parameter(s) is/are too short (index code: ' + parameter.index + ', minimum: ' + parameter.config.min + ', received length: ' + length + ')'
}, 422);
}
break;
}
/*
** All available type: boolean, number, string, object, function, undefined
*/
default: {}
}
}
/*
** Validate enum (Object)
*/
else {
let valid = false;
for (let k in parameter.type) {
if (parameter.type[k] === value) {
valid = true;
break;
}
}
if (!valid) {
throw new HttpException({
statusCode: 422,
error: 'Unprocessable Entity',
message: 'One/some parameter(s) is/are with incorrect value (index code: ' + parameter.index + ', expected value: [' + Object.keys(parameter.type).map(key => parameter.type[key]) + '], received: ' + value + ')'
}, 422);
}
//*** Your validation end here
}
}
action(arguments[parameter.index]);
}
let requiredParameters = Reflect.getOwnMetadata(requiredMetadataKey, target, propertyName);
if (requiredParameters) {
for (let parameter of requiredParameters) {
if (parameter.index >= arguments.length || arguments[parameter.index] == null) {
throw new HttpException({
statusCode: 422,
error: 'Unprocessable Entity',
message: 'One/some parameter(s) is/are missing (index code: ' + parameter.index + ')'
}, 422);
}
if (parameter.type != null) {
validate(false, parameter);
}
}
}
let optionalParameters = Reflect.getOwnMetadata(optionalMetadataKey, target, propertyName);
if (optionalParameters) {
for (let parameter of optionalParameters) {
if (parameter.type != null && arguments[parameter.index] != null) {
validate(true, parameter);
}
}
}
return method.apply(this, arguments);
}
}
}

The method decorator is where the validation logic really in. The code above defines a @Validate() decorator to place on our controller methods. It will read all the metadata supplied by our parameter decorators, to determine how each of our parameters should be validated.

For this example, it will first read all the parameters with @Required() to do the null checking. After that, it passes all the @Required() and @Optional() with type defined to do the type checking.

The above example covers only the validation of enum and primitive data types. Yet, you may sometimes have an object in your parameters. It is possible to also validate objects with custom decorators. However, I won’t recommend doing that as the object structure is usually very API-specific. It is difficult to have a generic way to describe those parameters. I suggest you to keep your decorator simple and do the specific object validation in your controller. Finally, hope this article is useful for you!

Web developer from Hong Kong. Most interested in Angular and Vue. Currently working on a Nuxt.js + NestJS project.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store