Repository: gcanti/tcomb (js)

Issue:
Would you consider an interface type?
e.g.
```
const Foo = t.inter({
  x: t.Number,
  y: t.Number
}, 'Foo');

const foo = Foo({ x: 1, y: 2 }); // throws - not a function.
Foo.is({ x: 1, y: 2 }); // true
Foo.is({ x: 1, y: 2, z: 3 }); // true
Foo.is({ x: 1, y: '2' }); // false
Foo.is({ x: 1 }); // false
t.assert(Foo.is({ x: 1 })); // throws - interface requirements not met...
```

Could be helpful in keeping things DRY.

Pull request description:
add interface combinator
Fix #195 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 48a2330..3743e63 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,7 @@
   - Chrome Dev Tools custom formatter for tcomb types
   - add `t.Integer` to standard types
   - add `t.Type` to standard types
+  - `interface` combinator
 
 # v3.0.0
 
diff --git a/README.md b/README.md
index 67042b6..018d55b 100644
--- a/README.md
+++ b/README.md
@@ -93,6 +93,7 @@ Write complex domain models in a breeze and with a small code footprint. Support
 * dictionaries
 * functions
 * recursive and mutually recursive types
+* interfaces
 
 **Immutability and immutability helpers**
 
diff --git a/docs/API.md b/docs/API.md
index b989fc8..9f5d56b 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -47,6 +47,7 @@ t.assert(x !== 0, 'cannot divide by x'); // => throws '[tcomb] cannot divide by
 
 - `t.String`: strings
 - `t.Number`: numbers
+- `t.Integer`: integers
 - `t.Boolean`: booleans
 - `t.Array`: arrays
 - `t.Object`: plain objects
@@ -56,6 +57,7 @@ t.assert(x !== 0, 'cannot divide by x'); // => throws '[tcomb] cannot divide by
 - `t.Date`: dates
 - `t.Nil`: `null` or `undefined`
 - `t.Any`: any value
+- `t.Type`: a `tcomb` type
 
 # The `irreducible` combinator
 
@@ -479,6 +481,83 @@ MinMax.is('abcde'); // => false
 }
 ```
 
+# The `interface` combinator
+
+There is an alias (`inter`) for IE8 compatibility.
+
+**Differences from structs**
+
+- `is` doesn't leverage `instanceof`, structural typing is used instead
+- allows additional props
+- also checks prototype keys
+
+**Signature**
+
+```js
+(props: {[key: string]: TcombType;}, name?: string) => TcombType
+```
+
+**Example**
+
+```js
+const Foo = t.interface({
+  x: t.Number,
+  y: t.Number
+}, 'Foo');
+
+var foo = Foo({ x: 1, y: 2 }); // => { x: 1, y: 2 }
+foo instanceof Foo; // => false (it's a pojo)
+Foo.is({ x: 1, y: 2 }); // => true
+
+// allows additional props
+Foo.is({ x: 1, y: 2, z: 3 }); // => true
+
+// checks types
+Foo.is({ x: 1, y: '2' }); // => false
+
+// doesn't allow missing props
+Foo.is({ x: 1 }); // => false
+
+const Point = t.struct({
+  x: t.Number,
+  y: t.Number
+}, 'Point');
+
+const Bar = t.interface({
+  point: Point
+}, 'Bar');
+
+// hydrates prop values
+const bar = Bar({ point: {x: 0, y: 0} });
+Point.is(bar.point); // => true
+
+const Serializable = t.interface({
+  serialize: t.Function
+})
+
+Point.prototype.serialize = function () {
+  ...
+};
+
+function doSerialize(serializable: Serializable) {
+  ...
+}
+
+doSerialize(bar.point); // => ok
+```
+
+**The `meta` object**
+
+```js
+{
+  kind: 'interface',
+  name: name,
+  identity: ...depends on props,
+  props: props
+}
+```
+
+
 # The `func` combinator
 
 **Signature**
diff --git a/docs/GUIDE.md b/docs/GUIDE.md
index 30dad19..c86883a 100644
--- a/docs/GUIDE.md
+++ b/docs/GUIDE.md
@@ -551,7 +551,7 @@ const user = User({
   anagraphic: {
     name: 'Giulio'
   }
-}); // thows tcomb] Invalid value {"name": "Giulio"} supplied to User/anagraphic: Anagraphic
+}); // throws [tcomb] Invalid value {"name": "Giulio"} supplied to User/anagraphic: Anagraphic
 
 const user = User({
   id: 'A40',
diff --git a/index.d.ts b/index.d.ts
index 625778a..f1e950f 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -82,7 +82,7 @@ declare module Tcomb {
   //
 
   type StructProps = {[key: string]: Constructor<any>};
-  type StructMixin = StructProps | Struct<any>;
+  type StructMixin = StructProps | Struct<any> | Interface<any>;
 
   interface Struct<T> extends Type<T> {
     new (value: T): T;
@@ -98,6 +98,23 @@ declare module Tcomb {
 
   export function struct<T>(props: StructProps, name?: string): Struct<T>;
 
+  //
+  // interface
+  //
+
+  interface Interface<T> extends Type<T> {
+    meta: {
+      kind: string;
+      name: string;
+      identity: boolean;
+      props: StructProps;
+    };
+    update: Update<T>;
+    extend<E extends T>(mixins: StructMixin | Array<StructMixin>, name?: string): Struct<E>;
+  }
+
+  export function interface<T>(props: StructProps, name?: string): Interface<T>;
+
   //
   // list
   //
diff --git a/index.js b/index.js
index 072d1ad..bbaeca3 100644
--- a/index.js
+++ b/index.js
@@ -51,6 +51,8 @@ t.union = require('./lib/union');
 t.func = require('./lib/func');
 t.intersection = require('./lib/intersection');
 t.subtype = t.refinement;
+t.inter = require('./lib/interface'); // IE8 alias
+t['interface'] = t.inter;
 
 // functions
 t.assert = t;
diff --git a/lib/createExtend.js b/lib/createExtend.js
new file mode 100644
index 0000000..55715e0
--- /dev/null
+++ b/lib/createExtend.js
@@ -0,0 +1,32 @@
+var assert = require('./assert');
+var isObject = require('./isObject');
+var isArray = require('./isArray');
+var mixin = require('./mixin');
+var isStruct = require('./isStruct');
+var isInterface = require('./isInterface');
+
+function createExtend(ctor) {
+  return function extend(mixins, name) {
+    if (process.env.NODE_ENV !== 'production') {
+      assert(isArray(mixins) && mixins.every(function (x) {
+        return isObject(x) || isStruct(x) || isInterface(x);
+      }), function () { return 'Invalid argument mixins supplied to extend(mixins, name), expected an array of objects or structs'; });
+    }
+    var props = {};
+    var prototype = {};
+    mixins.forEach(function (x) {
+      if (isObject(x)) {
+        mixin(props, x);
+      }
+      else {
+        mixin(props, x.meta.props);
+        mixin(prototype, x.prototype);
+      }
+    });
+    var ret = ctor(props, name);
+    mixin(ret.prototype, prototype);
+    return ret;
+  };
+}
+
+module.exports = createExtend;
\ No newline at end of file
diff --git a/lib/getDefaultInterfaceName.js b/lib/getDefaultInterfaceName.js
new file mode 100644
index 0000000..ac317b1
--- /dev/null
+++ b/lib/getDefaultInterfaceName.js
@@ -0,0 +1,9 @@
+var getTypeName = require('./getTypeName');
+
+function getDefaultInterfaceName(props) {
+  return '{' + Object.keys(props).map(function (prop) {
+    return prop + ': ' + getTypeName(props[prop]);
+  }).join(', ') + '}';
+}
+
+module.exports = getDefaultInterfaceName;
diff --git a/lib/interface.js b/lib/interface.js
new file mode 100644
index 0000000..127e8be
--- /dev/null
+++ b/lib/interface.js
@@ -0,0 +1,95 @@
+var assert = require('./assert');
+var isTypeName = require('./isTypeName');
+var String = require('./String');
+var Function = require('./Function');
+var isObject = require('./isObject');
+var create = require('./create');
+var getTypeName = require('./getTypeName');
+var dict = require('./dict');
+var getDefaultInterfaceName = require('./getDefaultInterfaceName');
+var isIdentity = require('./isIdentity');
+var is = require('./is');
+var createExtend = require('./createExtend');
+
+var extend = createExtend(inter);
+
+function inter(props, name) {
+
+  if (process.env.NODE_ENV !== 'production') {
+    assert(dict(String, Function).is(props), function () { return 'Invalid argument props ' + assert.stringify(props) + ' supplied to interface(props, [name]) combinator (expected a dictionary String -> Type)'; });
+    assert(isTypeName(name), function () { return 'Invalid argument name ' + assert.stringify(name) + ' supplied to interface(props, [name]) combinator (expected a string)'; });
+  }
+
+  var displayName = name || getDefaultInterfaceName(props);
+  var identity = Object.keys(props).map(function (prop) { return props[prop]; }).every(isIdentity);
+
+  function Interface(value, path) {
+
+    if (process.env.NODE_ENV === 'production') {
+      if (identity) {
+        return value; // just trust the input if elements must not be hydrated
+      }
+    }
+
+    if (process.env.NODE_ENV !== 'production') {
+      path = path || [displayName];
+      assert(isObject(value), function () { return 'Invalid value ' + assert.stringify(value) + ' supplied to ' + path.join('/') + ' (expected an object)'; });
+    }
+
+    var idempotent = true;
+    var ret = {};
+    for (var k in props) {
+      var expected = props[k];
+      var actual = value[k];
+      var instance = create(expected, actual, ( process.env.NODE_ENV !== 'production' ? path.concat(k + ': ' + getTypeName(expected)) : null ));
+      idempotent = idempotent && ( actual === instance );
+      ret[k] = instance;
+    }
+
+    if (idempotent) { // implements idempotency
+      ret = value;
+    }
+
+    if (process.env.NODE_ENV !== 'production') {
+      Object.freeze(ret);
+    }
+
+    return ret;
+
+  }
+
+  Interface.meta = {
+    kind: 'interface',
+    props: props,
+    name: name,
+    identity: identity
+  };
+
+  Interface.displayName = displayName;
+
+  Interface.is = function (x) {
+    if (!isObject(x)) {
+      return false;
+    }
+    for (var k in props) {
+      if (!is(x[k], props[k])) {
+        return false;
+      }
+    }
+    return true;
+  };
+
+  Interface.update = function (instance, patch) {
+    return new Interface(assert.update(instance, patch));
+  };
+
+  Interface.extend = function (xs, name) {
+    return extend([Interface].concat(xs), name);
+  };
+
+  return Interface;
+}
+
+inter.getDefaultName = getDefaultInterfaceName;
+inter.extend = extend;
+module.exports = inter;
diff --git a/lib/isInterface.js b/lib/isInterface.js
new file mode 100644
index 0000000..c9ccdd0
--- /dev/null
+++ b/lib/isInterface.js
@@ -0,0 +1,5 @@
+var isType = require('./isType');
+
+module.exports = function isInterface(x) {
+  return isType(x) && ( x.meta.kind === 'interface' );
+};
\ No newline at end of file
diff --git a/lib/struct.js b/lib/struct.js
index 15e3cdf..be70323 100644
--- a/lib/struct.js
+++ b/lib/struct.js
@@ -2,41 +2,18 @@ var assert = require('./assert');
 var isTypeName = require('./isTypeName');
 var String = require('./String');
 var Function = require('./Function');
-var isArray = require('./isArray');
 var isObject = require('./isObject');
 var create = require('./create');
-var mixin = require('./mixin');
-var isStruct = require('./isStruct');
 var getTypeName = require('./getTypeName');
 var dict = require('./dict');
+var getDefaultInterfaceName = require('./getDefaultInterfaceName');
+var createExtend = require('./createExtend');
 
 function getDefaultName(props) {
-  return '{' + Object.keys(props).map(function (prop) {
-    return prop + ': ' + getTypeName(props[prop]);
-  }).join(', ') + '}';
+  return 'Struct' + getDefaultInterfaceName(props);
 }
 
-function extend(mixins, name) {
-  if (process.env.NODE_ENV !== 'production') {
-    assert(isArray(mixins) && mixins.every(function (x) {
-      return isObject(x) || isStruct(x);
-    }), function () { return 'Invalid argument mixins supplied to extend(mixins, name), expected an array of objects or structs'; });
-  }
-  var props = {};
-  var prototype = {};
-  mixins.forEach(function (struct) {
-    if (isObject(struct)) {
-      mixin(props, struct);
-    }
-    else {
-      mixin(props, struct.meta.props);
-      mixin(prototype, struct.prototype);
-    }
-  });
-  var ret = struct(props, name);
-  mixin(ret.prototype, prototype);
-  return ret;
-}
+var extend = createExtend(struct);
 
 function struct(props, name) {
 
@@ -93,8 +70,8 @@ function struct(props, name) {
     return new Struct(assert.update(instance, patch));
   };
 
-  Struct.extend = function (structs, name) {
-    return extend([Struct].concat(structs), name);
+  Struct.extend = function (xs, name) {
+    return extend([Struct].concat(xs), name);
   };
 
   return Struct;


Test patch:
diff --git a/test/declare.js b/test/declare.js
index 3bd9fda..99c0b38 100644
--- a/test/declare.js
+++ b/test/declare.js
@@ -51,7 +51,7 @@ describe('t.declare([name])', function () {
         Nameless1({});
       }, 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));
+        assert.ok(/\[tcomb\] Invalid value .+ supplied to Struct{thing: Declare\$[0-9]+}\/thing: Struct{thing: Declare\$[0-9]+} \(expected an object\)/m.test(err.message));
         return true;
       });
       var Nameless2 = t.declare();
@@ -132,10 +132,10 @@ describe('t.declare([name])', function () {
     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)');
+      }, '[tcomb] Invalid value 12 supplied to Struct{b: ?Struct{a: ?A}}/b: ?Struct{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)');
+      }, '[tcomb] Invalid value 13 supplied to Struct{a: ?A}/a: ?A (expected an object)');
     });
 
     it('should throw if the type was not defined', function () {
diff --git a/test/getTypeName.js b/test/getTypeName.js
index 9b22363..d920c8e 100644
--- a/test/getTypeName.js
+++ b/test/getTypeName.js
@@ -24,6 +24,8 @@ describe('t.getTypeName(type)', function () {
   var NamedFunc = t.func(t.String, t.String, 'NamedFunc');
   var NamelessIntersection = t.intersection([t.String, t.Number]);
   var NamedIntersection = t.intersection([t.String, t.Number], 'NamedIntersection');
+  var NamelessInterface = t.inter({a: t.String, b: t.Number});
+  var NamedInterface = t.inter({a: t.String, b: t.Number}, 'NamedInterface');
 
   it('should return the name of a function', function () {
     assert.deepEqual(t.getTypeName(function myname(){}), 'myname');
@@ -40,10 +42,11 @@ describe('t.getTypeName(type)', function () {
     assert.deepEqual(t.getTypeName(NamedDict), 'NamedDict');
     assert.deepEqual(t.getTypeName(NamedFunc), 'NamedFunc');
     assert.deepEqual(t.getTypeName(NamedIntersection), 'NamedIntersection');
+    assert.deepEqual(t.getTypeName(NamedInterface), 'NamedInterface');
   });
 
   it('should return a meaningful name of a Nameless type', function () {
-    assert.deepEqual(t.getTypeName(NamelessStruct), '{}');
+    assert.deepEqual(t.getTypeName(NamelessStruct), 'Struct{}');
     assert.deepEqual(t.getTypeName(NamelessUnion), 'String | Number');
     assert.deepEqual(t.getTypeName(NamelessMaybe), '?String');
     assert.deepEqual(t.getTypeName(NamelessEnums), '"a" | "b"');
@@ -53,6 +56,7 @@ describe('t.getTypeName(type)', function () {
     assert.deepEqual(t.getTypeName(NamelessDict), '{[key: String]: String}');
     assert.deepEqual(t.getTypeName(NamelessFunc), '(String) => String');
     assert.deepEqual(t.getTypeName(NamelessIntersection), 'String & Number');
+    assert.deepEqual(t.getTypeName(NamelessInterface), '{a: String, b: Number}');
   });
 
 });
diff --git a/test/interface.js b/test/interface.js
new file mode 100644
index 0000000..a125112
--- /dev/null
+++ b/test/interface.js
@@ -0,0 +1,143 @@
+/* globals describe, it */
+var assert = require('assert');
+var t = require('../index');
+var throwsWithMessage = require('./util').throwsWithMessage;
+
+var Point = t.struct({
+  x: t.Number,
+  y: t.Number
+});
+
+var PointInterface = t.inter({
+  x: t.Number,
+  y: t.Number
+});
+
+var HydrateInterface = t.inter({point: Point});
+
+describe('t.interface(props, [name])', function () {
+
+  describe('combinator', function () {
+
+    it('should throw if used with wrong arguments', function () {
+      throwsWithMessage(function () {
+        t.inter();
+      }, '[tcomb] Invalid argument props undefined supplied to interface(props, [name]) combinator (expected a dictionary String -> Type)');
+      throwsWithMessage(function () {
+        t.inter({a: null});
+      }, '[tcomb] Invalid argument props {\n  "a": null\n} supplied to interface(props, [name]) combinator (expected a dictionary String -> Type)');
+      throwsWithMessage(function () {
+        t.inter({}, 1);
+      }, '[tcomb] Invalid argument name 1 supplied to interface(props, [name]) combinator (expected a string)');
+    });
+
+  });
+
+  describe('constructor', function () {
+
+    it('should be idempotent', function () {
+      var p1 = PointInterface({x: 0, y: 0});
+      var p2 = PointInterface(p1);
+      assert.deepEqual(Object.isFrozen(p1), true);
+      assert.deepEqual(Object.isFrozen(p2), true);
+      assert.deepEqual(p2 === p1, true);
+    });
+
+    it('should accept only valid values', function () {
+      throwsWithMessage(function () {
+        PointInterface(1);
+      }, '[tcomb] Invalid value 1 supplied to {x: Number, y: Number} (expected an object)');
+      throwsWithMessage(function () {
+        PointInterface({});
+      }, '[tcomb] Invalid value undefined supplied to {x: Number, y: Number}/x: Number');
+    });
+
+    it('should have meta.identity = true if contains a type with identity = true', function () {
+      assert.equal(PointInterface.meta.identity, true);
+      assert.equal(HydrateInterface.meta.identity, false);
+    });
+
+    it('should hydrate fields', function () {
+      var hi = HydrateInterface({ point: { x: 0, y: 1} });
+      assert.equal(Point.is(hi.point), true);
+    });
+
+  });
+
+  describe('interface.extend', function () {
+    it('should handle an array of mixins', function () {
+      var Point3DInterface = t.inter.extend([PointInterface, {z: t.Number}], 'Point3DInterface');
+      assert.deepEqual(Point3DInterface.meta.name, 'Point3DInterface', 'name');
+      assert.deepEqual(Point3DInterface.meta.props.x, t.Number, 'x');
+      assert.deepEqual(Point3DInterface.meta.props.y, t.Number, 'y');
+      assert.deepEqual(Point3DInterface.meta.props.z, t.Number, 'z');
+    });
+  });
+
+  describe('#is(x)', function () {
+
+    it('should return true when x is an instance of the interface', function () {
+      assert.equal(PointInterface.is({ x: 1, y: 2 }), true);
+      assert.equal(PointInterface.is(Point({ x: 1, y: 2 })), true);
+    });
+
+    it('should check types', function () {
+      assert.equal(PointInterface.is({ x: 1, y: 'a' }), false);
+    });
+
+    it('should allow additional props', function () {
+      assert.equal(PointInterface.is({ x: 1, y: 2, z: 3 }), true);
+    });
+
+    it('shouldn\'t allow missing props', function () {
+      assert.equal(PointInterface.is({ x: 1 }), false);
+    });
+
+    it('should allow prototype methods', function () {
+      var Serializable = t.inter({
+        serialize: t.Function
+      });
+      Point.prototype.serialize = function () {};
+      assert.equal(Serializable.is(Point({ x: 1, y: 2 })), true);
+    });
+
+  });
+
+  describe('#update()', function () {
+
+    var instance = PointInterface({x: 0, y: 0});
+
+    it('should return a new instance', function () {
+      var newInstance = PointInterface.update(instance, {x: {$set: 1}});
+      assert.ok(PointInterface.is(newInstance));
+      assert.deepEqual(instance.x, 0);
+      assert.deepEqual(newInstance.x, 1);
+      assert.deepEqual(newInstance.y, 0);
+    });
+
+  });
+
+  describe('#extend(xs, [name])', function () {
+
+    it('should extend an existing interface', function () {
+      var Point3DInterface = PointInterface.extend({z: t.Number}, 'Point3DInterface');
+      assert.deepEqual(Point3DInterface.meta.name, 'Point3DInterface', 'name');
+      assert.deepEqual(Point3DInterface.meta.props.x, t.Number, 'x');
+      assert.deepEqual(Point3DInterface.meta.props.y, t.Number, 'y');
+      assert.deepEqual(Point3DInterface.meta.props.z, t.Number, 'z');
+    });
+
+    it('should handle an array as argument', function () {
+      var Mixin = [{b: t.Number, c: t.Boolean}];
+      var NewInterface = PointInterface.extend(Mixin, 'NewInterface');
+      assert.deepEqual(t.getTypeName(NewInterface), 'NewInterface');
+      assert.deepEqual(NewInterface.meta.props.x, t.Number);
+      assert.deepEqual(NewInterface.meta.props.y, t.Number);
+      assert.deepEqual(NewInterface.meta.props.b, t.Number);
+      assert.deepEqual(NewInterface.meta.props.c, t.Boolean);
+    });
+
+  });
+
+
+});
diff --git a/test/struct.js b/test/struct.js
index 288dd61..bfd6b2c 100644
--- a/test/struct.js
+++ b/test/struct.js
@@ -58,10 +58,10 @@ describe('t.struct(props, [name])', function () {
     it('should accept only valid values', function () {
       throwsWithMessage(function () {
         Point(1);
-      }, '[tcomb] Invalid value 1 supplied to {x: Number, y: Number} (expected an object)');
+      }, '[tcomb] Invalid value 1 supplied to Struct{x: Number, y: Number} (expected an object)');
       throwsWithMessage(function () {
         Point({});
-      }, '[tcomb] Invalid value undefined supplied to {x: Number, y: Number}/x: Number');
+      }, '[tcomb] Invalid value undefined supplied to Struct{x: Number, y: Number}/x: Number');
     });
 
   });
@@ -89,7 +89,7 @@ describe('t.struct(props, [name])', function () {
 
   });
 
-  describe('#extend(props, [name])', function () {
+  describe('#extend(xs, [name])', function () {
 
     it('should extend an existing struct', function () {
       var Point = t.struct({
@@ -113,18 +113,22 @@ describe('t.struct(props, [name])', function () {
       assert.deepEqual(NewType.meta.props.c, t.Boolean);
     });
 
-    it('should handle a struct (or list of structs) as argument', function () {
+    it('should handle an array of objects or structs or interfaces as argument', function () {
       var A = t.struct({a: t.String}, 'A');
       var B = t.struct({b: t.String}, 'B');
       var C = t.struct({c: t.String}, 'C');
       var MixinD = {d: t.String};
-      var E = A.extend([B, C, MixinD]);
+      var I = t.inter({i: t.String}, 'I');
+      I.prototype.imethod = function() {};
+      var E = A.extend([B, C, MixinD, I]);
       assert.deepEqual(E.meta.props, {
         a: t.String,
         b: t.String,
         c: t.String,
-        d: t.String
+        d: t.String,
+        i: t.String
       });
+      assert.equal(E.prototype.imethod === I.prototype.imethod, true);
     });
 
     it('should support prototypal inheritance', function () {