js-typed

Unopinionated runtime typing for Javascript

Usage no npm install needed!

<script type="module">
  import jsTyped from 'https://cdn.skypack.dev/js-typed';
</script>

README

js-typed

Un-opinionated runtime type-checking and Multiple Dispatch / Multimethods for Javascript.

WIP!

Should be fairly stable at this point but no promises yet.

Motivation

There are two schools of thought that seemingly dominate the landscape of adding types to Javascript: old-school compile-time static analysis (MS TypeScript, Facebook Flow) and implementing the fruits of category theory in Javascript (e.g. Sanctuary, FantasyLand, PureScript). Those approaches have their strengths and weaknesses.

Compile-time static analysis:

    • No runtime performance penalty.
    • Familiar to C++/C#/Java et al. programmers.
    • Safe.
    • Fail early, prevents many runtime errors.
    • No abstract types. Conflates the notion of types and classes, although both have interface types.
    • No runtime manipulation, all the type info is lost in compiling.
    • No true underlying mathematical formalism.

Category Theory Types:

    • Can capture very complex types and relationships, can be abstract.
    • Extensible.
    • Mathematically formalized.
    • Familiar to ML-Family programmers.
    • Safe.
    • CT Types do not necessarily map to Javascript types intuitively.
    • Requires (for most) learning a new coding style / way of modeling problems.

This library exists because I find neither of those lists acceptable. I want safe(r), useful types that are closer to the underlying Javascript types for accessibility, but without losing the dynamism and runtime extensibility (and ability to capture complex types). The trade-offs are performance and safety: runtime type checks have their cost and they (by definition) occur at runtime, so no compile-time feedback.

So why even have them? Several reasons. Firstly, moving the failure earlier in the process. Functions checked by js-typed are auto-curried and the type checks occur as they receive arguments, meaning that instead of getting an error deep into a running program the error is more likely to occur during initialization. Secondly, js-typed can define types that could only be confirmed by a predicate check at runtime, e.g. a finite number type. Lastly, and perhaps most importantly, defining function signatures that are accessible at runtime enables polymorphic functions (i.e. multiply dispatched functions / multimethods) to be easily written without lots of boilerplate.

And you can have (some) of the best of both worlds: to the extent that multiple dispatch is not required and one is only interested in static type safety its easy enough to layer js-typed on top of a static analyzer like flow. Only use js-typed when you need to incrementally check a curried function, want a run-time check on input, or need multiple dispatch.

This may be a sort of Sisyphusian tilting at windmills (not to mention promiscuous mixing of metaphors), but we'll give it the ol' college try. One last thing that certainly deserves mention is contracts.js, a library that borrows from Racket's notion of contracts. Although I like the ideas in Racket contracts, and to the extent that I've looked at it I like contracts.js, but it uses a HM-ish syntax for defining contracts, and just like with PureScript et al. I'd prefer something a little closer to native Javascript. It also seems to be unmaintained: as of this writing it hasn't had a commit in almost a year.

Types

To get around some issues with typing, js-typed uses predicate or duck types, i.e. a value is recognized as being a certain type if it passes a predefined predicate test. In addition, js-typed can work with concrete types (and better than the underlying Javascript built-in operations).

js-typed will recognize all built-ins for their 'real' type: null, [], /rsts/, etc, will be 'null', 'array', 'regexp', and so on. In addition to accurately capturing the concrete types of Javascript's built-ins, js-typed provides some useful predicate types:

  • nil: returns true on null and undefined.
  • some: opposite of nil, avoids property access / assignment errors.
  • clean: type of Object.create(null)*.
  • key: can be used as property accessor on a Javascript object, i.e. String or Symbol.
  • primitive: Javascript primitive types: Number, String, Boolean, undefined, Symbol, and, for our purposes here, null.
  • reference: Non-primitive types (e.g. Object, Array, RegExp)
  • any: aliases '*', __ returns true for any argument.

* Object.create(null) is special, because it does not entirely uphold the contract of a Javascript object (e.g. has no toString method). Therefor, isType('object', Object.create(null)) returns false.

Some of these types are actually sum types: nil is the sum type of null and undefined, key is the sum type of string and symbol, etc. Sum types are defined using the sumType function, detailed below.

Promises Promises

If you are polyfilling things like Promise, Map, Set, Symbol, etc. for cross-browser support then I recommend that you define sensible predicate-checked types for those using this library as it will otherwise see them as plain Javascript objects (it will correctly type native implementations automatically). See Defining Types below.

Defining New Types:

js-typed provides the defType function for defining new types. defType takes a name for the type , a predicate for testing them, and an (optional) type constructor. defType then registers the type and returns an object with two methods: is and of. It is not necessary to capture the return value.

import * as types from 'js-typed';
let Foo = class {};
let FooType = types.defType('foo', x => x instanceof Foo, types.guardClass(Foo));

// js-typed will now recognize the foo type
types.isType('foo', new Foo); //true
FooType.is(new Foo); //true
FooType.of({}) instanceof Foo //true

This utility makes it possible to describe interesting types:

types.defType('numeric', x => !Number.isNaN(+x)); //can be coerced to a usable number
types.defType('positive', x => types.isType('number', x) && x > 0);

// or for a even more complex case, first we'll define a constructor:
function makeNonEmpty(...args) {
  switch (args.length) {
    case 0: throw new Error('non-empty cannot be constructed with no arguments');
    case 1:
      if (types.isType('array', args[0])) {
        if (!args[0].length) {
          throw new Error("Empty array cannot be non-empty");
        }
        return args[0];
      }
      if (types.isType('string', args[0])) {
        if (!args[0].length) {
          throw new Error("Empty string cannot be non-empty");
        }
        return args[0];
      }
      if (types.isType('number', args[0])) {
        if (!args[0]) {
          throw new Error("Zero cannot be non-empty");
        }
        return args[0];
      }
      if (types.isType('object', args[0])) {
        if (!Object.keys(args[0]).length) {
          throw new Error("Empty object cannot be non-empty");
        }
        return args[0];
      }
      // fall-through
    case 2: return args;
  }
}

// then register the type with a predicate and capture the return value:
let NonEmpty = types.defType(
  'not-empty',
  x => {
    return (
      x instanceof NonEmpty ||
      (types.isType('string', x) && x.length !== 0)  ||
      (types.isType('array', x)  && x.length !== 0)  ||
      (types.isType('number', x) && x !== 0)         ||
      (types.isType('object', x) && Object.keys(x).length !== 0)
    );
  },
  makeNonEmpty
});

NonEmpty.of(1,2,3); // [1,2,3]
NonEmpty.of(['a','b']); // ['a', 'b']
NonEmpty.is(''); // false

The return value of the defType function is an object with at least two methods: is applies the predicate tests to the arguments and of applies the type constructor. The type constructor by convention / definition should return a value that passes the predicate check or throw an error.

Note that notion is not to conflate the type NonEmpty with the value that has type NonEmpty: an array literal like [1,2,3] is still a NonEmpty as far as js-typed is concerned even though we didn't create it with the NonEmpty constructor we defined above. Note also that you must supply at least a predicate. The constructor defaults to Object.

Sum Types

To define a type that can be one of several types js-typed provides the sumType function which composes two or more types, similar to tagged unions in C. For instance we can define the 'nil' type as the union of the 'null' and 'undefined' types:

types.sumType('nil', 'null', 'undefined');

Protocols

If you need to describe a spec that subtypes can implement, you can use defProtocol. The three arguments to defProtocol are the name, a class to serve as a template, and an optional predicate that defaults to the argument being an instance of the template class.

let Functor = types.defProtocol(
  'functor',
  types.guardClass(class Functor {
    constructor() {}

    map(f) {
      return f(this);
    }
  }),
  a => a && types.isType('function', a.map)
);

Functor.is([]); // true

// now we can implement the spec:
let Mappable = types.defType('mappable', x => x && types.isType('function')).implements(Functor);
let m = Mappable.of({});
m.foo = 3;
m.map(x => x.foo); // 3

// but that's kinda boring, so lets do something more interesting. Protocols can themselves be
// extended:
let Monad = types.defProtocol(
  'monad',
  m => {
    return types.isType('functor', m) &&
    types.isType('function', m.mbind)
  },
  class Monad {
    constructor(name) {

      // test, insofar as is possible, that the subclass is in fact a Monad
      if (this.constructor.mbind === Monad.mbind) {
        throw new Error(`${name} must override the default mbind static method`);
      }

      // reference type equality will not hold, so warn instead of throw
      if (this.mbind(IDENTITY)(this) !== this.map(IDENTITY)) {
        console.warn(`cannot infer identity property for Monad ${name}`);
      }
    }

    static mbind() {
      // meant to be overriden
    }
  }
).implements(Functor);

// then given a concrete implementation:
let Maybe = types.defType(
  'maybe',
  a => a instanceof Maybe,

  // don't worry about the guardClass bit, for now just know that it make a class callable without
  // 'new'
  types.guardClass(class Maybe {
    constructor(value) {

      // NOTE: this is for illustrative purposes, in real life one might prefer a more robust
      // data-hiding pattern like Symbols or WeakMaps
      this.__value = value;
    }

    map(f) {
      return types.isType('nil', this.__value) ? this : new Maybe(f(this.__value));
    }

    toString() {
      return `Maybe(${this.__value})`;
    }

    static mbind(f) {
      return function(maybe) {
        let result = f(maybe.__value);
        return types.isType('maybe', result) ? result : Maybe.of(result);
      }
    }
  })
).implements(Monad);

let maybe5 = (() => [5, null][Math.floor(Math.random() * 2)]);

let y = 0;
let maybe10 = maybe5.map(x => { y = 1; return x * 2 });

// now there are two possibilities, either maybe10 is a Maybe with value 10 and y is 1, or maybe10
// is a Maybe(null) and y is still zero because the mapped function never ran.

Additionally js-typed supplies two curried utility functions to aid in writing predicates: hasProp and respondsTo.

let hasFoo = types.hasProp('foo');
hasFoo({foo: 3}); // true
types.hasProp('bar', {bar: null}); // false, only true if prop exists and is non-null

let hasFooMethod = types.respondsTo('foo');
hasFooMethod({foo: 3}); // false
hasFooMethod({foo: function() { return 'foo'; }}); // true

You can also tag a value as having a particular type using tag. You can even tag a constructor to automatically tag all of its instances:

let foo = {};
types.tag('foo', foo);
types.isType('foo', foo); // true
let Bar = types.tag('bar', class {});
types.isType('bar', new Bar()); // true

Guarding functions

Now that we've defined some types, we want to use them to avoid writing a bunch of tedious boilerplate checks to avoid getting an error like 'cannot read property foo of 'undefined''. js-typed supplies the guard function to do just that.

let add = types.guard(['number', 'number'], (a, b) => a + b);
add(3, 3); // 6
add(3, 'a'); // throws exception
let getFoo = types.guard('exists', x => x.foo);
getFoo({foo: 3}); // 3
getFoo('a'); // undefined
getFoo(null); // throws because it doesn't match the signature.

A couple of other things to note about guarded functions is that they are auto-curried, can have a specified arity (even beyond the type-checked arguments), and can take either a type or an array of types to check against. If you supply types to check it will infer the desired arity from the number of types unless you supply one that's longer that the number of types. The type checking is incremental: a curried function will throw as soon as you pass it an argument it can't match, even if it doesn't have all of its arguments yet.

let add3 = add(3);
add3(4); // 7
let addAndRaise = types.guard(['number','number','number'], (a, b, c) => (a + b) ** c);
let partial = addAndRaise(3);
let almost = partial('a'); // throws
partial(1, 2); // 16

// here only the first argument will be checked to make sure its a function, but the function won't
// actually be called until all the arguments are present. This pattern can obviate the need for an
// anonymous function that serves no purpose other than to gather arguments and delay execution of
// the callback.
let takesCallBack = types.guard(3, 'function', (fn, a, b) => fn(a, b));

// We can also guard constructors using guardClass:
let guardedDate = types.guard(['number', 'number', 'number'], Date);
let is2014 = guardedDate(2014);
let isJan2014 = is2014(0);
isJan2014(1); // Date: Wed Jan 01 2014 00:00:00

Guarded constructors are also auto-curried, and may be called with or without new.

Multiple Dispatch

We can also fake multiple dispatch / multimethods using js-typed's Dispatcher function:

let takesMany = new types.Dispatcher([
  [['string', 'string'], (a, b) => a + b],
  [['number', 'number'], (a, b) => a * b]
]);

takesMany('Hello ', 'World'); // Hello World
takesMany(6, 7); // 42

Lets say we want to add a case where if the argument is an array it returns the sum of the array's contents: something like [['array'], arr => arr.reduce(sum)]. However, that array case is not very safe, lets define a new type and use that instead:

types.defType('Array<Number>', x => types.isType('array', x) && x.every(y => !Number.isNaN(+y)));
takesMany.add([['Array<Number>'], arr => arr.reduce(sum)]);

takesMany([1,2,3]); // 6
takesMany(['1','2','3']); // throws an exception because it has no matching signature.

We can also add a default case for when it doesn't match any other type signatures:

takesMany.setDefault(x => x);
takesMany(['1','2','3']); // ['1','2','3']

The syntax though isn't the prettiest. Fortunately, the Dispatcher will also take guarded functions and automatically use their guarded signature:

let repeater = types.guard(['number', 'string'], (a, b) => b.repeat(a));
takesMany.add(repeater);
takesMany(3, 'a'); // 'aaa'