In this article, we are going to delve more in the NestJS ecosystem with its built-in validation mechanism. It integrates with the class-validator and class-transformer packages from typestack. And even without NestJS, they bring a lot of value to any typescript project.
Wa have already talked about GraphQL with NestJS, but feel free to get a quick refresh with this article! I am assuming you have an up and running NestJS application with GraphQL running. So we’ll start from there
Setup
Let’s install the library we talked about in the introduction. You can find out more information about how it’s integrated and how to use it in the official documentation.
npm i --save class-validator class-transformer
So what are those libraries for?
class-validator
: Adds handy decorator on top of class fields for data validationclass-transformer
: A toolbox to be able to transform a plain json object into a javascript class (with the methods!) and other cool methods to handle json object and javascript classes.
In this case, the class-transformer is mandatory to be able to use the NestJS ValidationPipe
that enables the
validator (as annotation/decorator) from the class-validator to work.
Implementation
Native validator
The class-validator package comes with a bundle of validator ready to use.
(Just to name a few IsDefined()
, IsEmpty()
, IsIn(...)
, …)
That should most likely reduce some of the boilerplate to get started.
Then you can add them within your class, in this case we are going to apply them on the input for a createAuthor
mutation to create an author for a book.
import { IsNotEmpty } from 'class-validator';
import { Field, InputType } from '@nestjs/graphql';
@InputType()
export class CreateAuthorInput {
@IsNotEmpty()
@Field(() => String)
name: string;
}
And as easy as that, we specify that the name is supposed to be not empty. The goal will be to do some input validation before it goes to be created, so we can reject early on bad input.
But on its own, it doesn’t do much; you need to configure your app to use it.
In your NestJS main.ts
application apply the validation pipe:
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({})); // <-- This
await app.listen(3001);
}
With app.useGlobalPipes(new ValidationPipe({}))
, the mutation with a bad input will be intercepted and a payload with
the error will be returned.
As usual in GraphQL, it will still return an HTTP 200
.
Custom validator
To create your own validator to use as a decorator within your code, is almost as easy. In our case, we want to validate that the created author name is not Robert for some reason 🥲
For that we’ll have:
import { registerDecorator, ValidationOptions } from 'class-validator';
export function IsNotRobert(validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
name: 'isNotRobert', // <-- Name of the constraint in the error
target: object.constructor,
propertyName: propertyName,
constraints: [],
options: {
message: 'name must not be Robert',
...validationOptions,
},
validator: {
validate(value: any) {
return value !== 'Robert';
},
},
});
};
}
As you can see, although it is not a class,
the method’s name is IsNotRobert
with a I
to match the pattern of the other decorator.
I don’t need to use the options as a variable, but it could be used to pass the message for a custom one per field, for
example (Passing it like @IsNotRobert({ message: 'This <field> should not be Robert })
).
Not to use it, same as the other one:
@InputType()
export class CreateAuthorInput {
@IsNotEmpty()
@IsNotRobert()
@Field(() => String)
name: string;
}
And now your API should be ready! But first let me show present the tests I had written to implement that.
Testing
Unit test validator
To be able to unit test the validators, you can use the class-validator’s own validate
function to make sure
it errors for the right reason.
it('can not be named Robert 🥲', async () => {
const input = new CreateAuthorInput('Robert');
await validate(input).then(errors => {
expect(errors).toHaveLength(1);
expect(errors[0].constraints).toHaveProperty('isNotRobert', 'name must not be Robert');
});
});
You can have more than one error, meaning by stacking validation decorator you could have multiple errors triggered. The ones we have set here do not intersect, so we are checking against the first error only.
e2e test validator
We have seen previously how to write an e2e test for a GraphQL NestJS API in this article. So I will go to the essential!
As we added the validation pipe in our main.ts
before, we also need to do it here for the test application.
Be sure that they match to avoid discrepancies in test.
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AuthorModule],
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe({})); // <-- same as in `main.ts`
await app.init();
});
While creating the test app for the AuthorModule
, we apply the same validation pipe as our app, so we’ll get the same
error behaviours on bad input.
Error
Let’s spend a bit of time analysing and refining the error from the validation to our taste.
Initial error
The initial error is not the one that is going to be sent back to you via API call. It is the one generated by the validation 😵💫 So many errors it gets confusing!
The initial validation error looks like:
const ValidationError = {
target: {
name: ''
},
value: '',
property: 'name',
children: [],
constraints: {
isNotEmpty: 'name should not be empty'
}
}
If you remember the unit test, we were checking against the constraints
, now it makes more sense as we see it.
The target
is the createAuthor
input here, and the property is the one with validator that is targeted.
By default
When doing a test trying to create an author that does not respect the rule, the validation pipe be triggered and return an error.
By default, with GraphQL the extensions
code will be INTERNAL_SERVER_ERROR
, but the message will be the one defined
within the custom validator:
{
"message": "Unexpected error value: [{ property: \"name\", message: \"name must not be Robert\" }]",
"locations": [{ "line": 1, "column": 53 }],
"path": [
"createAuthor"
],
"extensions": {
"code": "INTERNAL_SERVER_ERROR"
}
}
As we can see it has the locations
and path
which are common for GraphQL.
However, it’s not actually an internal error (which in HTTP usually translates to a 5xx
error) but more of a user error
coming from the client (which in HTTP would translate to a 4xx
error).
So it’s not ideal! Let’s see if we can do better.
With ValidationPipe’s configuration
We are using NestJS v10, error may slightly differ depending on the version
Now the validation pipe can use the class-transformer to try to match the error message to a class instead. You can set it in the pipe’s configuration with:
new ValidationPipe({ transform: true })
This will convert the validation error into a 400 (Bad Request) and appear slightly different:
{
"errors": [
{
"message": "Bad Request Exception",
"locations": [{ "line": 1, "column": 53 }],
"path": [
"createAuthor"
],
"extensions": {
"code": "BAD_REQUEST",
"originalError": {
"message": ["name should not be empty"],
"error": "Bad Request",
"statusCode": 400
}
}
}
],
"data": null
}
No more suggestion of an internal error, it is now clear that it is a user error.
The only downside is that the error message is now Bad Request Exception
instead of something more explicit,
the original error message can still be found though in the extensions
with the original error.
You can achieve a similar result as the transform
option with the exceptionFactory
which can be passed in the
validation pipe option.
Using
exceptionFactory
, thetransform
option will be ignored.
It allows you fine grain control on how the exception will look like. It takes the initial error from the validator as an input (or any other validation error that could be thrown by the application).
But we can still go a bit further and improve the error!
Using GraphQL Driver format error
Let’s say we have the transform: true
configuration in the validation pipe, now we can get that error and instead
of displaying as GraphQL default error, we can format it to our liking.
It is not mandatory, but having the message as the sum of the original response could improve readability.
And that can be achieved through the GraphQL driver formatError
configuration.
GraphQLModule.forRoot({
driver: ApolloDriver,
formatError: (error) => ({
message: error.extensions['originalError']?.['message'].join(', ') ?? error.message,
path: error.path,
locations: error.locations,
extensions: {
code: error.extensions['code'],
},
}));
})
Remember that error.extensions['originalError']
might not be always there depending on the validation error you may
be throwing in the application.
So make just you adjust properly for your use case.
Unfortunately, it doesn’t allow transforming one error into multiple, but it shouldn’t hinder readability.
Like in this example where I added a new name should not be empty
error, all errors are within the message instead of
being stuck away in the original error:
{
"errors": [
{
"message": "name is too short, name should not be empty",
"path": ["createAuthor"],
"locations": [{ "line": 1, "column": 53 }],
"extensions": { "code": "BAD_REQUEST" }
}
],
"data": null
}
With that, you should have leeway to customize the errors how you want! There are multiple moving pieces, so it gets confusing when the change you are making at one place getting overwritten auto magically at another place.
In a previous article, we were differentiating user errors from system errors, returning within the mutation this kind of Business error as expected from the mutation’s response type. Here with this type of error, it’s considered as a GraphQL “system” error. So this way is a bit of a shift in paradigm, both are correct, and NestJS does facilitate treating every error this way. We try to Keep it simple 🤓!