Repository: gcanti/tcomb
Pull request #157 (2015-12-01 15:07:37)

t.declare([name]) enables construction of recursive types
This code contains a proposed pseudo-combinator to enable the construction of recursive types (https://github.com/gcanti/tcomb/issues/71). The proposed API is described in the "Recusive types" section of the GUIDE.

This is a minimal addition that (to my knowledge) doesn't break existing features and does not require changes to the existing combinators. The `displayName` path for unnamed structs sometimes contains the type name (or a placeholder, when an explicit name was not provided) for types constructed via `declare`, e.g.
`{value: Int, b: B}/b: B` where `B` is in fact `{a: A}`. This is mainly due to limitations of the proposed implementation, but - in general - it would anyway not be possible to represent all the (mutually recursive) struct types with their fields (it would not terminate).


Patch:
diff --git a/GUIDE.md b/GUIDE.md
index d5fa34b..30ebccb 100644
--- a/GUIDE.md
+++ b/GUIDE.md
@@ -611,6 +611,45 @@ t.func([t.Number, t.Number], t.Number).is(func(t.Number, t.Number).of(id));
 2. Typed functions' codomains are checked when they return
 3. The domain and codomain of a typed function's type is checked when the typed function is passed to a function type (such as when used as an argument in another typed function)
 
+## Recursive types
+
+`t.declare([name])` declares a type name to be used in other combinators without requiring a definition right away. This enables the construction of recursive or mutually recursive types.
+
+```js
+var Tree = t.declare("Tree");
+
+Tree.define(t.struct({
+  value: t.Num,
+  left: t.maybe(Tree),
+  right: t.maybe(Tree)
+}));
+
+var bst = Tree({
+  value: 5,
+  left: Tree({
+    value: 2
+  }),
+  right: Tree({
+    left: Tree({
+      value: 6
+    }),
+    value: 7
+  })
+});
+```
+
+```js
+var A = t.declare("A");
+
+var B = t.struct({
+  a: t.maybe(A)
+});
+
+A.define(t.struct({
+  b: t.maybe(B)
+});
+```
+
 ## Updating immutable instances
 
 You can update an immutable instance with the provided `update` function:
diff --git a/README.md b/README.md
index 66df573..5cdd4e8 100644
--- a/README.md
+++ b/README.md
@@ -181,7 +181,6 @@ console.log(result); // => 'a number'
 * [icedam](https://github.com/winkler1/icedam)
 * [immutable-store](https://github.com/christianalfoni/immutable-store)
 * [ObjectModel](https://github.com/sylvainpolletvillard/ObjectModel)
-* [rfx](https://github.com/ericelliott/rfx)
 
 # License
 
diff --git a/index.js b/index.js
index 5a341ed..15e17f2 100644
--- a/index.js
+++ b/index.js
@@ -1018,6 +1018,44 @@ function match(x) {
   exports.fail('Match error');
 }
 
+// All the .declare-d types should be clearly different from each other thus they should have
+// different names when a name was not explicitly provided.
+var nextDeclareUniqueId = 1;
+
+function declare(name) {
+  if (process.env.NODE_ENV !== 'production') {
+    assert(isTypeName(name), function () { return 'Invalid argument name ' + name + ' supplied to declare([name]) (expected a string)'; });
+  }
+
+  var type;
+
+  function Declare(value, path) {
+    if (process.env.NODE_ENV !== 'production') {
+      assert(!isNil(type), function () { return 'Type declared but not defined, don\'t forget to call .define on every declared type'; });
+    }
+    return type(value, path);
+  }
+
+  Declare.define = function (spec) {
+    if (process.env.NODE_ENV !== 'production') {
+      assert(isType(spec), function () { return 'Invalid argument type ' + exports.stringify(spec) +  ' supplied to define(type) (expected a type)'; });
+      assert(isNil(type), function () { return 'Declare.define(type) can only be invoked once'; });
+      assert(isNil(spec.meta.name) && Object.keys(spec.prototype).length === 0, function () { return 'Invalid argument type ' + exports.stringify(spec) + ' supplied to define(type) (expected a fresh, unnamed type)'; });
+    }
+
+    type = spec;
+    mixin(Declare, type, true); // true because it overwrites Declare.displayName
+    Declare.displayName = name || type.displayName;
+    Declare.meta.name = name || Declare.meta.name;
+    Declare.prototype = type.prototype;
+  };
+
+  Declare.displayName = name || (getTypeName(Declare) + "$" + nextDeclareUniqueId);
+  Declare.prototype = null;
+  nextDeclareUniqueId += 1;
+  return Declare;
+}
+
 mixin(exports, {
   is: is,
   isType: isType,
@@ -1057,5 +1095,6 @@ mixin(exports, {
   dict: dict,
   func: func,
   intersection: intersection,
-  match: match
+  match: match,
+  declare: declare
 });


Test patch:
diff --git a/test/declare.js b/test/declare.js
new file mode 100644
index 0000000..0efebd8
--- /dev/null
+++ b/test/declare.js
@@ -0,0 +1,135 @@
+/* globals describe, it */
+var assert = require('assert');
+var t = require('../index');
+var throwsWithMessage = require('./util').throwsWithMessage;
+
+var A = t.declare("A");
+
+var B = t.struct({
+  a: t.maybe(A)
+});
+
+A.define(t.struct({
+  b: t.maybe(B)
+}));
+
+var aValue = A({
+  b: B({
+    a: A({
+      b: null
+    })
+  })
+});
+
+describe('t.declare([name])', function () {
+
+  describe('combinator', function () {
+
+    it('should throw if used with wrong arguments', function () {
+
+      assert.throws(function () {
+        t.declare(t.Num);
+      }, function(err) {
+        assert.strictEqual(err instanceof Error, true);
+        assert.ok(/\[tcomb\] Invalid argument name function (.|\n)* supplied to declare\(\[name\]\) \(expected a string\)/m.test(err.message));
+        return true;
+      });
+
+      throwsWithMessage(function () {
+        var D = t.declare("D");
+        D.define("not a type");
+      }, '[tcomb] Invalid argument type "not a type" supplied to define(type) (expected a type)');
+
+    });
+
+    it('should throw if define-d multiple times', function () {
+      throwsWithMessage(function () {
+        var D = t.declare("D");
+        D.define(t.list(t.Any));
+        D.define(t.list(t.Any));
+      }, '[tcomb] Declare.define(type) can only be invoked once');
+    });
+
+    it('should have a fresh name for different declares when not explicitly provided', function() {
+      var Thing1 = t.declare();
+      Thing1.define(t.struct({
+        thing: Thing1
+      }));
+      assert.throws(function() {
+        Thing1({});
+      }, function(err) {
+        assert.strictEqual(err instanceof Error, true);
+        assert.ok(/\[tcomb\] Invalid value .+ supplied to {thing: Declare\$[0-9]+}\/thing: {thing: Declare\$[0-9]+} \(expected an object\)/m.test(err.message));
+        return true;
+      });
+      var Thing2 = t.declare();
+      assert.ok(t.getTypeName(Thing1) != t.getTypeName(Thing2));
+    });
+
+    it('an instance of the declared type should satisfy instanceof, if the concrete type is a struct', function() {
+      assert.ok(aValue instanceof A);
+    });
+
+    it('should have the expected names', function() {
+      var ANum = t.declare('A');
+      ANum.define(t.list(t.Any));
+
+      assert.deepEqual('A', A.displayName);
+      assert.deepEqual('A', A.meta.name);
+    });
+
+    it('should support adding functions to the prototype, when allowd by the concrete type', function() {
+      var ANum = t.declare('A');
+      ANum.define(t.struct({
+        a: t.Num
+      }));
+      function afun() { return 42 }
+      ANum.prototype.afun = afun;
+      assert.equal(42, ANum({a: 13}).afun());
+    });
+
+    it('should throw when defined with a non-fresh type', function() {
+      throwsWithMessage(function () {
+        var ANum = t.declare();
+        ANum.define(t.Num);
+      }, '[tcomb] Invalid argument type undefined supplied to define(type) (expected a fresh, unnamed type)');
+    });
+
+  });
+
+  describe('constructor', function () {
+
+    it('should be idempotent', function () {
+      var p1 = A(aValue);
+      var p2 = A(p1);
+      assert.deepEqual(p2 === p1, true);
+    });
+
+    it('should accept only valid values', function () {
+      throwsWithMessage(function () {
+        A({b: 12});
+      }, '[tcomb] Invalid value 12 supplied to {b: ?{a: ?A}}/b: ?{a: ?A} (expected an object)');
+      throwsWithMessage(function () {
+        A({b: B({ a: 13 }) });
+      }, '[tcomb] Invalid value 13 supplied to {a: ?A}/a: ?A (expected an object)');
+    });
+
+    it('should throw if the type was not defined', function () {
+      throwsWithMessage(function () {
+        var D = t.declare("D");
+        D({a: A({}) });
+      }, '[tcomb] Type declared but not defined, don\'t forget to call .define on every declared type');
+    });
+
+  });
+
+  describe('#is(x)', function () {
+
+    it('should return true when x is an instance of the struct', function () {
+      var a = new A(aValue);
+      assert.ok(A.is(a));
+    });
+
+  });
+
+});