Comments and suggestions are welcome, please open an issue here or send a Pull Request.
The examples of this guide use tcomb, a library for Node.js and the browser which allows you to check the types of JavaScript values at runtime with a simple and concise syntax. It's great for Domain Driven Design and for adding safety to your internal code.
Let's start with a simple task, adding runtime type checking to the following function:
// a, b should be numbers
function sum(a, b) {
return a + b;
}An easy way to achieve this goal is to add asserts (also called invariants) to the function.
Signature
(guard: boolean, message?: string | () => string) => voidThe assert function is the main building block of tcomb.
Example
import t from 'tcomb';
function sum(a, b) {
t.assert(typeof a === 'number', 'argument a is not a number');
t.assert(typeof b === 'number', 'argument b is not a number');
return a + b;
}Note. The assert fails if guard !== true.
When an assert fails, the default behavior is throwing a TypeError.
sum(1, 's'); // => throws TypeErrorTip. If you are using the Chrome DevTools, set "Pause on exceptions" on the "Sources" panel in order to leverage the power of the debugger (Watch, Call Stack, Scope, Breakpoints, etc...)
Clicking the "sum" item in the Call Stack shows the offending line of code:
Note that message can also be a function so you can define lazy error messages (i.e. the function contained in message is called only when the assert fails). As a benefit you can get detailed messages without too much overhead (JSON.stringify is expensive):
import t from 'tcomb';
function sum(a, b) {
t.assert(typeof a === 'number', () => `invalid value ${JSON.stringify(a)} supplied to argument a, expected a number`);
t.assert(typeof b === 'number', () => `invalid value ${JSON.stringify(b)} supplied to argument b, expected a number`);
return a + b;
}
sum(1, {x: 1}); // throws '[tcomb] invalid value {"x":1} supplied to argument b, expected a number'You can customise the failure behavior overriding the exported fail function:
Signature
(message: string) => voidExample
t.fail = function (message) {
console.error(message);
};
sum(1, 's'); // => outputs to console 'invalid value "s" supplied to argument b, expected a number'If a tree falls in a forest and no one is around to hear it, does it make a sound?
Asserts are very useful in development but you may want to strip them out in production. Just wrap the asserts in conditional blocks checking the process.env.NODE_ENV global variable:
function sum(a, b) {
if (process.env.NODE_ENV !== 'production') {
// this code exists and then executes only in development
t.assert(typeof a === 'number', 'argument a is not a number');
t.assert(typeof b === 'number', 'argument b is not a number');
}
return a + b;
}then use modules like envify (for browserify) or webpack.DefinePlugin (for webpack) in your production build.
TODO. Example configuration for browserify and webpack.
Writing asserts can be cumbersome, let's see if we can write less. Every type defined with tcomb, included the built-in type t.Number (the type of all numbers), owns a static predicate is(x: any) => boolean useful for type checking:
import t from 'tcomb';
function sum(a, b) {
t.assert(t.Number.is(a), 'argument a is not a number');
t.assert(t.Number.is(b), 'argument b is not a number');
return a + b;
}Still too verbose. Luckily every tcomb's type is a glorified identity function, that is it returns the value passed in if is good and throws (but only in development!) otherwise:
function sum(a, b) {
t.Number(a); // throws if a is not a number
t.Number(b); // throws if b is not a number
return a + b;
}
sum(1, 's'); // => throws '[tcomb] Invalid value "s" supplied to Number'The following built-in types are exported by tcomb:
t.String: stringst.Number: numberst.Boolean: booleanst.Array: arrayst.Object: plain objectst.Function: functionst.Error: errorst.RegExp: regular expressionst.Date: dates
There are 2 additional built-in types:
t.Nil:nullorundefinedt.Any: any value (useful when you need a temporary placeholder or an escape hatch...)
Another way to type-check the sum function is to use the func combinator:
Signature
(domain: Array<TcombType>, codomain: TcombType, name?: string) => TcombTypeExample
const SumType = t.func([t.Number, t.Number], t.Number);
// of() returns a type-checked version of its argument
const sum = SumType.of((a, b) => a + b);
sum(1, 's'); // => throws '[tcomb] Invalid value "s" supplied to [Number, Number]/1: Number'The string 'Invalid value "s" supplied to [Number, Number]/1: Number' is an example of the concise format used by tcomb in order to point to the offended type. You can read it like this:
The value of the second element of the tuple [Number, Number] is "s" but a Number was expected
The general format of an error message is:
'Invalid value <value> supplied to <context>'where <context> is a slash-separated string with the following properties:
- the first element is the name of the root type
- the following elements have the format:
<field name>: <field type>(arrays are 0-based)
If you are using babel, there is another option (the one I personally use the most): adding type annotations and use the babel-plugin-tcomb plugin.
Example
function sum(a: t.Number, b: t.Number) {
return a + b;
}
sum(1, 's'); // => throws '[tcomb] Invalid value "s" supplied to Number'tcomb exports the most common types but you can define your own. Say you want to add support for Maps, you can use the irreducible combinator:
Signature
(name: string, predicate: (x: any) => boolean) => TcombTypeExample
const MapType = t.irreducible('MapType', (x) => x instanceof Map);
function size(map) {
MapType(map);
return map.size;
}
console.log(size(new Map())); // => 0
console.log(size({})); // throws '[tcomb] Invalid value {} supplied to MapType'Note. The built-in types (t.String, t.Number, etc...) are defined with the irreducible combinator.
I can use t.String in order to type-check generic strings, but often I want more precise types. Say I want to represent the Password type: the type of all strings whose length is greater then 6:
const Password = t.irreducible('Password', (x) => t.String.is(x) && x.length > 6);This is too verbose, let's use the refinement combinator:
Signature
(type: tcombType, predicate: (x: any) => boolean, name?: string) => TcombTypeExample
const Password = t.refinement(t.String, (s) => s.length > 6);
Password('short'); // throws '[tcomb] Invalid value "short" supplied to {String | <function1>}'Note that in the predicate (s) => x.length > 6 I no longer check for strings since the refinement combinator automatically handles that for me.
For better error messages, give the type a name:
const Password = t.refinement(t.String, (s) => s.length > 6, 'Password');
Password('short'); // throws '[tcomb] Invalid value "short" supplied to Password'Example. Representing an integer between 1 and 5
const Integer = t.refinement(t.Number, (n) => n % 1 === 0, 'Integer');
const PositiveInteger = t.refinement(Integer, (i) => i > 0, 'PositiveInteger');
const Rating = t.refinement(PositiveInteger, (r) => r <= 5, 'Rating');
Rating(10); // throws '[tcomb] Invalid value 10 supplied to Rating'Note that you can see the name of the failing type and the message in the Call Stack panel:
Every type defined with tcomb owns a static meta member containing at least the following properties:
kinda stringy enum containing the type kind (equal to'irreducible'for irreducibles or'subtype'for refinements)namea string, the name of the typeidentitya boolean,trueif the type constructor can be treated as the identity function in production builds
The refinements meta object owns an additional property type containing its supertype:
Example
Integer.meta.type === t.Number; // => true
console.log(Integer.meta);We can exploit this information in order to define a function returning the type chain of a refinement:
function getTypeChain(type) {
const name = type.meta.name;
const supertype = type.meta.type;
if (!supertype) {
// no more supertypes
return name;
}
// recurse
return [name].concat(getTypeChain(supertype));
}
console.log(getTypeChain(Rating)); // => ["Rating", "PositiveInteger", "Integer", "Number"]When a type represent a finite list of strings, instead of a refinement you can use the enums combinator:
Signature
(map: Object, name?: string) => TcombTypewhere map is a hash whose keys are the enums (values are free).
Example
const Country = t.enums({
IT: 'Italy',
US: 'United States'
}, 'Country');
Country('FR'); // throws '[tcomb] Invalid value "FR" supplied to Country (expected one of ["IT", "US"])'Example. Building a select input from an enum
The meta object of an enum owns an additional property map containing the keys:
JSON.stringify(Country.meta.map); // => {"IT":"Italy","US":"United States"}We can use that map to dinamically generate the options of a select (which will be always in sync with your domain model):
import t from 'tcomb';
import React from 'react';
import { render } from 'react-dom';
import _ from 'lodash';
render(
<select>
{_.map(Country.meta.map, (text, value) => <option key={value} value={value}>{text}</option>)}
</select>,
document.getElementById('app')
)A more general abstraction:
function isEnums(x) {
return x && x.meta && x.meta.kind === 'enums';
}
function renderSelect(type) {
// type checking
if (process.env.NODE_ENV !== 'production') {
t.assert(isEnums(type), () => `Invalid argument type ${JSON.stringify(type)} supplied to renderSelect(), expected an enum`);
}
return (
<select>
{_.map(type.meta.map, (text, value) => <option key={value} value={value}>{text}</option>)}
</select>
);
}
renderSelect(); // throws [tcomb] Invalid argument type undefined supplied to renderSelect(), expected an enum
renderSelect(Country); // okIf you don't care of values you can use enums.of:
Example
// values will mirror the keys
const Country = t.enums.of('IT US', 'Country');
// same as
const Country = t.enums.of(['IT', 'US'], 'Country');
// same as
const Country = t.enums({
IT: 'IT',
US: 'US'
}, 'Country');Problem. So far the values were always required, but what if I must handle optional values?
Solution. There is the maybe combinator and it can be composed with every other combinator:
Signature
(type: tcombType, name?: string) => TcombTypeExample. An optional country.
t.maybe(Country)(); // ok
t.maybe(Country)(undefined); // ok
t.maybe(Country)(null); // ok
t.maybe(Country)('IT'); // ok
t.maybe(Country)(1); // throwsClasses are common compound data structures (also called product types) thus there is a combinator for them, the struct combinator:
Signature
(props: {[key: string]: TcombType;}, name?: string) => TcombTypeExample
const Point = t.struct({
x: t.Number,
y: t.Number
}, 'Point');
// the keyword new is optional
const point = Point({ x: 1, y: 2 });Methods are defined as usual:
Point.prototype.toString = function() {
return `(${this.x}, ${this.y})`;
};
console.log(String(point)); // => '(1, 2)'Example. The User struct.
const emailRegExp = ...long regexp here...
const Email = t.refinement(t.String, (s) => emailRegExp.test(s), 'Email');
const Role = t.enums.of([
'admin',
'guest'
], 'Role');
const User = t.struct({
id: t.String,
email: Email,
role: Role,
birthDate: t.maybe(t.Date),
name: t.maybe(t.String),
surname: t.maybe(t.String)
}, 'User');
const user = User({
id: 'A40',
email: 'user@example.com',
role: 'admin',
name: 'Giulio'
});Generally I prefer flat structures, however structs can be nested:
const Anagraphic = t.struct({
birthDate: t.maybe(t.Date),
name: t.maybe(t.String),
surname: t.maybe(t.String)
}, 'Anagraphic')
const User = t.struct({
id: t.String,
email: Email,
role: Role,
anagraphic: Anagraphic
}, 'User');
const user = User({
id: 'A40',
email: 'user@example.com',
role: 'admin',
anagraphic: {
name: 'Giulio'
}
});Problem. What if I want to express the following invariant? "Name and surname are optional, but they must be both null or both valued".
Solution. Define a refinement of Anagraphic:
const BaseAnagraphic = t.struct({
birthDate: t.maybe(t.Date),
name: t.maybe(t.String),
surname: t.maybe(t.String)
}, 'BaseAnagraphic');
const Anagraphic = t.refinement(
BaseAnagraphic,
(x) => t.Nil.is(x.name) === t.Nil.is(x.surname),
'Anagraphic'
);
const User = t.struct({
id: t.String,
email: Email,
role: Role,
anagraphic: Anagraphic
}, 'User');
const user = User({
id: 'A40',
email: 'user@example.com',
role: 'admin',
anagraphic: {
name: 'Giulio'
}
}); // throws [tcomb] Invalid value {"name": "Giulio"} supplied to User/anagraphic: Anagraphic
const user = User({
id: 'A40',
email: 'user@example.com',
role: 'admin',
anagraphic: {
name: 'Giulio',
surname: 'Canti'
}
}); // okExample. JSON serialisation / deserialisation.
Serialising an instance of User is easy, just call JSON.stringify:
const user = User({
id: 'A40',
email: 'user@example.com',
role: 'admin',
anagraphic: {}
});
console.log(JSON.stringify(user));
// => {"id":"A40","email":"user@example.com","role":"admin","anagraphic":{"birthDate":null,"name":null,"surname":null}}Deserialising is easy as well since struct constructors accept an object as argument:
const json = JSON.parse(JSON.stringify(user));
console.log(User(json)); // => a User instanceThe problem comes when you add a birthDate and try to deserialize:
const user = User({
id: 'A40',
email: 'user@example.com',
role: 'admin',
anagraphic: {
birthDate: new Date(1973, 10, 30)
}
});
const json = JSON.parse(JSON.stringify(user));
console.log(User(json));
// throws '[tcomb] Invalid value "1973-11-29T23:00:00.000Z" supplied to User/anagraphic: Anagraphics/birthDate: ?Date'Problem. '1973-11-29T23:00:00.000Z' is a string but Anagraphics wants a Date.
Solution. Use runtime type introspection to define a general reviver.
Disclaimer. This is just an example, it doesn't mean to be complete (for a complete implementation see the lib/fromJSON module).
import _ from 'lodash';
function deserialize(value, type) {
if (t.Function.is(type.fromJSON)) {
return type.fromJSON(value);
}
const { kind } = type.meta;
switch (kind) {
case 'struct' :
return type(_.mapValues(value, (v, k) => deserialize(v, type.meta.props[k])));
case 'maybe' :
return t.Nil.is(value) ? null : deserialize(value, type.meta.type);
case 'subtype' : // the kind of refinement is 'subtype' (for legacy reasons)
return deserialize(value, type.meta.type);
default : // enums, irreducible
return value;
}
}
// then configure your types
t.Date.fromJSON = (s) => new Date(s);
console.log(deserialize(json, User)); // => see the image belowNote. tcomb is able to deserialize the nested structs: the value of the field anagraphic is an instance of BaseAnagraphic.
In order to keep my domain model DRY I use a few techniques:
Say you want to define a User as a struct with the following fields:
- name
- surname
import t from 'tcomb'
export default t.struct({
email: t.refinement(t.String, (s) => /@/.test(s), 'Email'),
name: t.String,
surname: t.String
}, 'User')The problem is that you can't re-utilize the email type as it's coupled with the User type. A quick solution is to split the definitions:
// file Email.js
import t from 'tcomb'
export default t.refinement(t.String, (s) => /@/.test(s), 'Email')
...
// file User.js
import t from 'tcomb'
import Email from './Email'
export default t.struct({
email: Email,
name: t.String,
surname: t.String
}, 'User')When 2 structs share a subset of their fields you can use mixins:
// file IdentifiedUser.js
import User from './User'
// every field of User plus an id
export default User.extend({ id: t.String }, 'IdentifiedUser')All tcomb's types are introspectables at runtime (see the meta object in the docs)
// file Message.js
import User from './User'
export default t.struct({
email: User.meta.props.email, // automatically synced
message: t.String
}, 'Message')




