Using automated runtime data validation decorators in TypeScript

; Date: Thu Mar 10 2022

Tags: TypeScript

A new package for TypeScript enables us to automatically check validity of runtime data, without having to explicitly call a validation library. Instead, the runtime-data-validation package lets us attach decorators describing data validity, and arranges for validation to be automatically performed on assigning a value to a property, or when calling a method.

In JavaScript there is no type checking, and no built-in data validation. That's a core feature of the language, and is part of why it's so popular. At the same time we're using JavaScript for ever-larger projects, and in theory that makes it necessary to have software tools to assist us writing cleaner applications. In other languages that means strict type checking and runtime data checks, but in JavaScript we don't want to stray too far from the core propositions.

TypeScript is serving as a method for the JavaScript to experiment with advanced language features. It adds type declarations and compile-time type checking to what is otherwise JavaScript, for example. Recently, while updating my book (www.amazon.com) Quick Start to using Typescript and TypeORM in Node.js applications (sponsored link), I learned about using TypeScript decorators. Following from that, I created a multi-part article series on using and implementing TypeScript decorators. One article was about developing TypeScript decorators for performing runtime data validation in JavaScript/TypeScript. From that article, I created the (github.com) runtime-data-validation package which is a complete implementation of data validation, in TypeScript programs, which automatically validates data in two scenarios:

  1. Assignments to set accessor properties with attached validation decorators
  2. Calls to class methods where parameters have attached validation decorators

This article is about using the package, while the previous article is about implementing decorators that perform runtime data validation. The approach is different from packages like Joi or AJV where the programmer must explicitly write data validation code. With runtime-data-validation, the programmer attaches decorators to accessors or method parameters, and from there those methods are automatically protected against invalid data.

For a trivial example, consider this class:

import {
    ValidateAccessor, IsFloatRange,
    conversions
} from 'runtime-data-validation';

const { ToFloat } = conversions;

export class SpeedExample {

    #speed: number;

    @ValidateAccessor<number | string>()
    @IsFloatRange(10, 70)
    set speed(nc: number | string) { 
        this.#speed = ToFloat(nc);
    }
    get speed() { return this.#speed; }

}

There is a private property, #speed, and an acessor function pair, speed, that handles getting and setting values to the private property. The @IsFloatRange decorator ensures that the value is a floating point number between 10 and 70. It also handles a string value that happens to be numeric.

const sp = new SpeedExample();
sp.speed = VALUE;

For values like 30, or '30', this executes correctly. For a value like 300 or '300' or '300 miles/hr', an error is thrown. The error prevents the set accessor from executing, and preventing the property from being given an incorrect value.

In other words, the core value proposition for the runtime-data-validation package is for properties or methods protected by validation decorators, they will not receive incorrect data. Any incorrect data will be detected causing an error to be thrown.

Installing the runtime-data-validation package

This tutorial is written for a Node.js project. Using runtime-data-validation in a browser-side project has not been tested. Therefore, set up a Node.js project, and configure it for TypeScript development. For example:

$ npm init -y
$ npm install typescript @types/node --save-dev

Next, there are two settings to make for supporting decorators. In your tsconfig.json file make these settings:

{
    "compilerOptions": {
        ...
        "experimentalDecorators": true,
        "emitDecoratorMetadata": true,
        ...
    }
}

Once you have this setup, install the package as so:

$ npm install runtime-data-validation --save

The example code shown above should now run.

For detailed documentation of the package see: (runtime-data-validation-js.github.io) https://runtime-data-validation-js.github.io

Type checking versus data validation

Data validation is a little different than type checking. For example, a variable title with the type string can theoretically contain any string value. But, the application may require this variable to have text in a given locale/language, or to be constrained to a specific format.

Type checking ensures that a variable, title, is a string. Data validation ensures that, not only does a variable have the correct type, but that its current value is within the required constraints.

Execution Decorators and Validation Decorators

There are two kinds of decorators exported from runtime-data-validation.

import {
    IsIntRange, IsInt, IsFloatRange, IsFloat,
    ...
    ValidateParams, ValidateAccessor,
} from 'runtime-data-validation';

Most of the Validation Decorators are spelled as @IsXYZZY where it is meant to be read as "value is type XYZZY". These perform data validation, with each containing a validation function.

The two Execution Decorators are @ValidateParams and @ValidateAccessor. The first is used with methods, and handle execution of validation decorators attached to method parameters, while the second is used with set accessors, and handle execution of validation decorators attached to the accessor.

To see both in action let's examine a larger example:

import {
    ValidateAccessor, ValidateParams,
    IsIntRange, IsInt, IsFloatRange,
    conversions
} from 'runtime-data-validation';

const { ToFloat, ToInt } = conversions;

export class ValidateExample {

    #year: number;

    @ValidateAccessor<number>()
    @IsIntRange(1990, 2050)
    @IsInt()
    set year(ny: number | string) { this.#year = ToInt(ny); }
    get year() { return this.#year; }

    @ValidateParams
    area(
        @IsFloatRange(0, 1000) width: number | string,
        @IsFloatRange(0, 1000) height: number | string
    ) {
        return ToFloat(width) * ToFloat(height);
    }

}

There are two items here protected by validation decorators:

  1. The private property #year and the set accessor year, is declared with @IsInt and @IsIntRange. Those decorators ensure its value will be an integer between 1990 and 2050. The @ValidateAccessor decorator handles execution of the validation decorators.
  2. THe method are has two parameters, each of which has @IsFloatRange attached, which ensures the value is in the range of 0 to 1000. The @ValidateParams decorator handles execution of the validation decorators.

A test of this class might look like this:

const ve = new ValidateExample();

ve.year = 1999;
console.log({
    year: ve.year,
    area: ve.area(10, 200)
});

// Error: Value 2150 not an integer between 1990 and 2050
ve.year = 2150;

console.log(ve.area('10', '200'));

// Error: Value 'two hundred' not a float between 0 and 1000
console.log(ve.area('10', 'two hundred'));

For the assignments or method calls where values are within the accepted range, the statements execute with no errors. FOr the statements with out-of-range or invalid values, the errors shown here will be thrown. For example, the programmer who wrote 'two hundred' may have meant well, but the text string is not numerical.

Validation functions

In the TypeScript documentation we're urged to implement type guard functions to aid runtime type checking. The function is to receive a data item, inspect that data item, and determine if it has the correct shape.

You might have a type declared:

type CustomType = {
    flag1: boolean;
    speed: number;
    title?: string;
};

How does your program determine if a variable has this shape? You cannot use instanceof in this case. The idea is to inspect the fields and make sure they match.

const isCustomType = (value: any): value is CustomType => {
    if (typeof value !== 'object') {
        return false;
    }
    // Both of these fields are required in
    // the type definition
    if (typeof value?.flag1 !== 'boolean'
     || typeof value?.speed !== 'number') {
        return false;
    }
    // Speed must be a positive number
    if (value.speed < 0) {
        return false;
    }
    // This field is optional, and therefore can
    // be undefined.
    if (typeof value?.title !== 'undefined'
     && typeof value?.title !== 'string') {
        return false;
    }
    // Be certain that `title` is correct
    if (typeof value?.title !== 'undefined'
     && typeof value?.title === 'string') {
        if (!validators.isAscii(value.title)
         || !validators.matches(value.title, /^[a-zA-Z0-9 ]+$/)) {
            return false;
        }
    }
    return true;
};

The parameter has the type any allowing any variable to be tested. The return type, value is CustomType, is what TypeScript calls a type predicate. In behavior it is indistinguishable from a boolean, in other words it is a true or false condition. Within this function are some tests to ensure value has the correct fields to match Customtype.

The last section of the function goes beyond type checking. This checks the format of the string in valu.title. For whatever reason, this application wants it to be ASCII text containing letters, digits, and spaces.

Notice that to validate the text format, we use validators.isAscii and validators.matches. There are corresponding validation decorators for each of these functions. But this context does not allow us to use decorators.

Contained within runtime-data-validation is a set of validation functions corresponding to the validation decorators. Each validation decorator uses one of the validation functions.

To use these functions:

import { validators } from 'runtime-data-validation';

This import gives access to the validators object, which contains the validation functions.

We've already seen how to use these functions. They have the same behavior as type guard functions, meaning they accept a value, then provide a boolean informing you whether the value matches.

Custom validation decorators

While the runtime-data-validation package contains a long list of validation decorators, they are not the be-all-end-all of data validation. There is close to a zillion other data validations which can be performed.

For that purpose, the generateValidationDecorator function allows an application to create validation decorators. For example, a validation decorator for the CustomType type might look like this:

function IsCustom() {
    return generateValidationDecorator(
        isCustomType,
        `Value :value: is not a CustomType`);
}

Each TypeScript decorator is implemented by a function. That means the IsCustom function can be used as a decorator as @IsCustom().

The first parameter to generateValidationDecorator is the function to use for validation. The function will receive a value, and is expected to return a boolean where true means the value is okay. In other words, this function needs a type guard function. The second parameter is a string to print if the value was not okay, and :value: receives the result of util.inspect(value).

Suppose you need to further customize the validation. For example, the speed field might have an acceptable range of values.

function IsCustomRange(min: number, max: number) {
    return generateValidationDecorator(
        (value) => {
            if (!isCustomType(value)) return false;
            const ct = <CustomType>value;
            if (ct.speed < min || ct.speed > max) return false;
            return true;
        },
        `Value :value: is not a CustomType`);
}

This follows the decorator factory pattern. The parameters for the outer function configure how values will be validated. In this case the code of the new validation function first calls isCustomType, and then it checks speed to validate that it is within the required range.

To use this, attach @IsCustomRange(0, 150) to an accessor function or method parameter.

Summary

The runtime-data-validation package fills a gap in JavaScript/TypeScript functionality. With it, we can automatically validate values before they land in our object instances.

It provides a long list of built-in validation decorators and validation functions. Additionally, it lets you easily define your own validation decorators.

Because of how TypeScript implements decorators, the decorators can only be attached to set accessors and to method parameters. To validate other things, use the validation functions, or implement your own type guard functions.

About the Author(s)

(davidherron.com) David Herron : David Herron is a writer and software engineer focusing on the wise use of technology. He is especially interested in clean energy technologies like solar power, wind power, and electric cars. David worked for nearly 30 years in Silicon Valley on software ranging from electronic mail systems, to video streaming, to the Java programming language, and has published several books on Node.js programming and electric vehicles.

Books by David Herron

(Sponsored)