Easy Validation with Class Validator


Before we can consider persisting data off to storage, we need to ensure the data is in a known good shape. This means validation. And there is a really nice library that solves this problem very effectively for us.

We will be using the Class Validator library.

This will solve all our validation concerns, and even take care of creating a lovely error object that we can return to our API consumers.

We didn't add this library at the start of our project as there is a touch extra setup required. Let's take care of this now.

npm i --save class-validator

Class Validator uses, as the name suggests, ES6's class keyword. It also makes heavy use of decorators, which are an experimental feature of JavaScript, at the time of writing.

How you choose to structure your validation classes is entirely up to you. For the purposes of this tutorial, as we will be validating incoming data from HTTP requests, I am going to create a directory called request, and have each 'type' of request as a separate class.

mkdir src/request

When our API consumer POST's data in to our /codereviewvideos endpoint, they will - ideally - be sending in JSON in the shape of:

{ "game": "game name here" }

Really simple.

I'm going to call this an AddGameRequest.

Again, whether you choose to test this class or not is entirely your call. A set of integration tests should cover it. But as it's so simple, I'm going to throw a quick test up anyway.

mkdir __tests__/request
touch __tests__/request/AddGameRequest.test.ts

Into which we will add a really fluffy test:

import { AddGameRequest } from '../../src/request/AddGameRequest';

describe("request/AddGameRequest", () => {

  it(`has the expected class properties'`, async () => {

    const addGameRequest = new AddGameRequest();
    addGameRequest.name = "a game name here";

    expect(addGameRequest.name).toBeDefined();

  });

});

In order to make this test pass we need:

touch src/request/AddGameRequest.ts

And the implementation:

export class AddGameRequest {

  name!: string;

}

This is the basic wrapper, but as yet, we are not making use of any of the validation decorators that Class Validator provides.

Testing Validation

Class Validator provides lots of helpful decorators to meet common validation needs. If the provided validators don't meet your specific needs, you can create your own, too.

Fortunately our needs are basic:

  • Must be a string
  • Must have length of 1 or greater
  • Must have length of 20 or fewer

I've added in the last validation rule to more easily demonstrate violation of a constraint, rather than any real world need.

Let's write some tests to cover the happy and sad paths:

import { AddGameRequest } from '../../src/request/AddGameRequest';
import { validate } from "class-validator";

describe("request/AddGameRequest", () => {

  let addGameRequest: AddGameRequest;
  const validatorOptions = {};

  beforeAll(() => {
    addGameRequest = new AddGameRequest();
  });

  it(`has the expected class properties'`, async () => {
    addGameRequest.name = "a game name here";
    expect(addGameRequest.name).toBeDefined();
  });

  describe(`'name' validation`, () => {

    it('is valid', async () => {
      for (let i = 1; i <= 20; ++i) {
        addGameRequest.name = "x".repeat(i);
        expect(
          await validate(addGameRequest, validatorOptions)
        ).toHaveLength(0);
      }
    });

    it('must be a string', async () => {
      addGameRequest.name = 123;
      expect(
        await validate(addGameRequest, validatorOptions)
      ).toHaveLength(1);
    });

    it('must have length of 1 character or greater', async () => {
      addGameRequest.name = '';
      expect(
        await validate(addGameRequest, validatorOptions)
      ).toHaveLength(1);
    });

    it('must have a length of 20 characters or fewer', async () => {
      addGameRequest.name = 'y'.repeat(21);
      expect(
        await validate(addGameRequest, validatorOptions)
      ).toHaveLength(1);
    });
  });
});

Now, TypeScript will pick up on one of these tests as being problematic straight away.

As we've defined AddGameRequest.name as a string, we cannot assign a number to that property, even during testing:

 FAIL  __tests__/request/AddGameRequest.test.ts
  ● Test suite failed to run

    TypeScript diagnostics (customize using `[jest-config].globals.ts-jest.diagnostics` option):
    __tests__/request/AddGameRequest.test.ts:30:7 - error TS2322: Type '123' is not assignable to type 'string'.

    30       addGameRequest.name = 123;

TypeScript implicitly tests this for us. Good, or bad? Well, I will leave this for you to decide.

To proceed, we need to remove that test.

Once removed, re-running the test show two fails:

 FAIL  __tests__/request/AddGameRequest.test.ts
  request/AddGameRequest
    ✓ has the expected class properties' (1ms)
    'name' validation
      ✓ is valid (22ms)
      ✕ must have length of 1 character or greater (1ms)
      ✕ must have a length of 20 characters or fewer (1ms)

And this is to be expected, as we haven't yet added the validation decorators to our AddGameRequest class. We can do that now:

import {IsString, Length} from "class-validator";

export class AddGameRequest {

  @IsString()
  @Length(1, 20)
  name!: string;

}

But as soon as we do, TypeScript blows up on us:

 FAIL  __tests__/request/AddGameRequest.test.ts
  ● Test suite failed to run

    TypeScript diagnostics (customize using `[jest-config].globals.ts-jest.diagnostics` option):
    src/request/AddGameRequest.ts:7:3 - error TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.

    7   name!: string;
        ~~~~

Fortunately the error message is really descriptive. It's telling us our tsconfig.json file needs updating. We need to add support for an experimental feature - decorators.

To do this, we need to stop the server / test runner, and update tsconfig.json:

{
  "compilerOptions": {
    "target": "es5",                          /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
    "module": "commonjs",                     /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
    "strict": true,                           /* Enable all strict type-checking options. */
    "esModuleInterop": true,                  /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */

    /* Experimental Options */
    "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */,
    "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "__tests__/**/*"]
}

Restarting the tests:

 PASS  __tests__/request/AddGameRequest.test.ts
  request/AddGameRequest
    ✓ has the expected class properties' (2ms)
    'name' validation
      ✓ is valid (80ms)
      ✓ must have length of 1 character or greater (16ms)
      ✓ must have a length of 20 characters or fewer

Awesome, we have validation.

Episodes