README
RTV.js
Runtime Verification Library for browsers and Node.js.
This library is isomorphic: It runs equally well in modern browsers and on the server with Node.js.
The latest versions of major browsers, and Node LTS releases, are supported.
Give it a test drive with RunKit!
Installation
npm install rtvjs
The package's ./dist
directory contains 3 types of builds:
rtv[.slim].js
: CJS (for use by bundlers)rtv.esm[.slim].js
: ESM (for use by bundlers)rtv.umd[.slim][.dev].js
: UMD (for use in browsers, self-contained)
The CJS and ESM builds require defining the process.env.NODE_ENV
to either "development"
or "production"
. The UMD 'dev' build is the equivalent of defining process.env.NODE_ENV = "development"
.
Slim
These builds are smaller in size to optimize on download time and bundling efficiency.
The .slim
CJS and ESM builds depend on @babel/runtime and lodash external dependencies. You will need to install those packages in addition to rtvjs
.
See the package.json's
devDependencies
to know what versions of those dependencies are required when using slim builds.
The .slim
UMD builds only depend on lodash being defined as the _
global:
<script src="https://unpkg.com/lodash"></script>
<script src="./dist/rtv.umd.slim.js"></script>
CJS
The CJS build can be used like this, typically in Node.js, or with a bundler like Webpack or Rollup:
const rtv = require('rtvjs');
Be sure to set process.env.NODE_ENV = "development"
if you want to enable the dev code it contains (e.g. deprecation warnings). To exclude the dev code, set process.env.NODE_ENV = "production"
(or any value other than "development"
).
Use the Webpack Define Plugin or the Rollup Replace Plugin, for example, to configure this in your build.
ESM
The ESM build can be used like this (note a default export is not provided):
import * as rtv from 'rtvjs'; // import all into an `rtv` namespace
import { verify, STRING, ... } from 'rtvjs'; // selective imports only
The CJS considerations above also apply to this build (externals and environment).
UMD
The UMD build comes in two files:
./dist/rtv.umd[.slim].dev.js
: For development. Non-minified, and includes dev code such as deprecation warnings (if any)../dist/rtv.umd[.slim].js
: For production. Minified, and excludes any dev code..slim
depends on_
as thelodash
global in both cases.
Use it like this:
// as a CommonJS module (e.g. Node.js)
const rtvjs = require('./dist/rtv.umd.js'); // OR: `./dist/rtv.umd.dev.js`
rtvjs.verify(...);
// as an AMD module (e.g. RequireJS)
define(['rtvjs'], function(rtvjs) {
rtvjs.verify(...);
});
<!-- as a global, when loaded via a <script> tag in HTML -->
<script src="./dist/rtv.umd.js"></script>
<script>rtvjs.verify(...)</script>
The non-slim builds are self-contained and optimized for browsers.
Documentation
This README
, as well as the API, are hosted at rtvjs.stefcameron.com.
Changes
Purpose
To provide an easy, intuitive way to perform validations at runtime on values whenever they cross the boundaries of an API or a function call.
Tools like TypeScript and Flow are useful for static analysis (i.e. as code is being written and then transpiled to regular JavaScript), but they come at a price and they don't work at runtime.
For example, they can't signal when there are integration issues between frontend and backend systems that are being co-developed. In one conversation, an API may be designed to return an object with certain properties. Later on, an on-the-fly decision to alter the implementation (yes, it happens in spite of the best intentions and processes), or simply a bug in the implementation, may result in an object that is missing an expected property, or has a property with an unexpected value.
Let's consider a case where a "state" property, which is really an enumeration of string values, ends-up set to an unexpected state. What should a client do with an unexpected state when there's no implementation to back it up? Ignoring it could be an option, but perhaps not the best course of action. Even worse, the unexpected state somehow could trickle deep down into code before it finally causes an exception, making it really difficult to find the true source of the problem.
RTV.js can help signal the unexpected state by failing early, right at the API boundary:
async function getTodoList() {
const response = await fetch('/api/todos');
const json = await response.json();
// verify (require) that the response be a list of TODO items: this function
// will throw if `json` doesn't meet the specified typeset (requirement)
rtv.verify(json, [[{ // list of objects (could be empty)
// non-empty string
title: rtv.STRING,
// 'YYYY-MM-DD', or null
due: [rtv.EXPECTED, rtv.STRING, {exp: '\\d{4}-\\d{2}-\\d{2}'}],
// string (could be empty), null, or not even defined
note: [rtv.OPTIONAL, rtv.STRING]
}]]);
return json;
}
There may also be a need to ensure that a critical function call is being given the parameters it expects. Rather than write a series of if (!state) { throw new Error('state is required'); }
(which don't tell us much about what "state" is expected to be, other than it's required), it would be more helpful to have an easy way to express that "state" should be a non-empty string with a value in a given list (i.e. a value found in an enumeration).
RTV.js can help signal the unexpected state immediately when execution enters the function:
function applyState(state) {
rtv.verify(state, [rtv.STRING, {oneOf: ['on', 'off']}]);
if (state === 'on') {
// turn the lights on
} else {
// turn the lights off
}
}
applyState('on'); // ok
applyState('dimmed'); // ERROR
While tools like TypeScript and Flow have their merits, they come at a price. Typings or not, integration issues will remain. RTV.js allows you to check for types at runtime, when it really matters, and has a simple API so it's easy to learn.
Goals
The following statement verifies that the variable "state" is a non-empty string whose value is found in a list of permitted values:
rtv.verify(state, [rtv.STRING, {oneOf: ['on', 'off']}]);
The [rtv.STRING, {oneOf: ['on', 'off']}]
portion of the example above is called a typeset. It expresses the expectation for the value of the "state" variable.
Typesets must be:
- Easy to express, using rich types and qualifiers.
- Composable, whereby complex typesets can be built by combining multiple typesets into larger ones.
- Easy to customize, using custom validators when the existing types and arguments don't provide the exact verification needed on a value.
- Intuitive, using simple native JavaScript language constructs like strings (for types), inline Arrays
[]
for lists and complex typesets, and inline objects{}
for shapes (i.e. interfaces). - Serializable to JSON via
JSON.stringify()
so they can be easily transferred between systems.- Backend and frontend systems in JavaScript stacks could dynamically inform one another of expectations by sharing typesets.
- Similar to the
@context
property of a JavaScript Object for JSON-LD, an object's expected shape could be transferred along with the object itself. - With the exceptions of custom validator functions and the
ctor
property of shape object arguments.
Tutorials
Tutorials and example uses of the RTV.js library.
Getting Started
To make it clear, in this tutorial, which properties and functions from RTV.js, we'll start by importing everything into an rtv
object:
import * as rtv from 'rtvjs';
You could also drop the object and import individual names, such as:
import { check, verify, STRING, ... } from 'rtvjs';
Checks and Verifications
RTV.js provides two functions for verifying values against typesets. A typeset is simply a set of one or more types that form an expectation about the value:
rtv.verify(value, typeset); // will throw an error if verification fails
rtv.check(value, typeset); // returns the error instead of throwing it
Simple Types
Typesets can be strings, objects (shapes), functions (custom validators), or Arrays (multiple possibilities).
At their simplest, typesets are strings that represent type names like STRING
, INT
, DATE
, etc. See the full list of types here.
rtv.verify('Hello world!', rtv.STRING); // ok
rtv.verify('', rtv.STRING); // ERROR: a required string cannot be empty
Qualifiers
The first verification succeeds because the value is a non-empty string. The second one fails because the typeset uses the default qualifier, which is REQUIRED
. A required string cannot be empty (nor can it be null
or undefined
).
In some implementations, an empty string is considered a bad value because it's a falsy value in JavaScript, just like null
, undefined
, false
, 0
, and NaN
.
There are 3 other qualifiers, EXPECTED
, OPTIONAL
, and TRUTHY
. A typeset may only have one qualifier, and it must be specified before any types.
The only way to specify an alternate qualifier is to use an Array to describe the typeset: [<qualifier>, types...]
If we wanted to accept an empty string (or null
) as the value, we could use the EXPECTED
qualifier:
rtv.verify('Hello world!', [rtv.EXPECTED, rtv.STRING]); // ok
rtv.verify('', [rtv.EXPECTED, rtv.STRING]); // ok
rtv.verify(null, [rtv.EXPECTED, rtv.STRING]); // ok
If we had a variable which we expect to be an object whenever it's value is truthy, we could use the TRUTHY
qualifier, which would permit any falsy value, but require the value to be of a specified type otherwise:
let objectOrFalsy = false;
rtv.verify(objectOrFalsy, [rtv.TRUTHY, rtv.PLAIN_OBJECT]); // ok
objectOrFalsy = {hello: 'world!'};
rtv.verify(objectOrFalsy, [rtv.TRUTHY, rtv.PLAIN_OBJECT]); // ok
rtv.verify(objectOrFalsy, [rtv.TRUTHY, rtv.ARRAY]); // ERROR: value is not an array
// similar to how the following code would either print "world!" or not get executed
// depending on the truthiness of `objectOrFalsy`
if (objectOrFalsy) {
console.log(objectOrFalsy.hello);
}
Type Arguments
Some types accept arguments. Arguments are simple objects that map argument names to values, and immediately follow a type in a typeset. Once again, an Array must be used to describe the typeset. Type arguments are optional, unless otherwise stated; some types don't accept arguments.
The STRING
type accepts arguments, one of which is min
. It lets us specify the minimum length of the string. By default, when the qualifier is REQUIRED
, min
defaults to 1, but we can override that:
rtv.verify('Hello world!', [rtv.STRING, {min: 0}]); // ok
rtv.verify('', [rtv.STRING, {min: 0}]); // ok
rtv.verify(null, [rtv.STRING, {min: 0}]); // ERROR
This verifies the value cannot be null
or undefined
because of the (implied) REQUIRED
qualifier. However, it could be empty because the min
argument allows a zero-length string as the value.
Multiple Types
So far, we've seen simple typesets: Either just a string as the type name, or the type name and some arguments, and an optional qualifier that precedes it. There may be cases where a value could be one of multiple types. To verify against additional types, an Array is used to state all the possibilities: [<qualifier>, <type1>, <type1-args>, <type2>, <type2-args>, ...]
. This is called an "Array typeset", which we've already seen in the two previous sections.
Since a value can only be of a single type at any given time, Array typesets are evaluated using a short-circuit OR conjunction, which means the verification will pass as long as at least one type verifies the value (and verification will stop evaluating any other types against the value once a match is made).
For example, we could verify that a value is either a boolean, or a string that looks like a boolean:
const typeset = [rtv.BOOLEAN, rtv.STRING, {
exp: '^(?:true|false)