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
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
runtime-data-validation
package which is a complete implementation of data validation, in TypeScript programs, which automatically validates data in two scenarios:
- Assignments to
set
accessor properties with attached validation decorators - 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: 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:
- The private property
#year
and theset
accessoryear
, is declared with@IsInt
and@IsIntRange
. Those decorators ensure its value will be an integer between1990
and2050
. The@ValidateAccessor
decorator handles execution of the validation decorators. - THe method
are
has two parameters, each of which has@IsFloatRange
attached, which ensures the value is in the range of0
to1000
. 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.