Repository: plaid/sanctuary (js)

Issue:
descriptive "method missing" errors
For example:

```javascript
> S.Just(2).concat(S.Just(2))
TypeError: undefined is not a function
```

It would be nice to see something this instead:

```javascript
> S.Just(2).concat(S.Just(2))
TypeError: ‘Maybe#concat’ requires a semigroup as its first argument; received Just(2)
```


Pull request description:
maybe, either: descriptive errors for missing "concat" methods
Closes #84

I spent a surprisingly long time working this patch into its current form. I'm pleased with the current approach based on `assertMethodExists`.


Patch:
diff --git a/index.js b/index.js
index 3a448fd..67a2354 100644
--- a/index.js
+++ b/index.js
@@ -262,6 +262,21 @@
     });
   };
 
+  var assertMethodExists = function(method, x) {
+    if (x == null || typeof x[method] !== 'function') {
+      throw new TypeError(format(
+        '{repr} does not have {} {quote} method',
+        [x, R.test(/^[aeiou]/i, method) ? 'an' : 'a', method]
+      ));
+    }
+  };
+
+  //  invoke :: String -> [*] -> Accessible -> *
+  var invoke = R.curry(function(method, args, x) {
+    assertMethodExists(method, x);
+    return x[method].apply(x, args);
+  });
+
   //  negativeZero :: a -> Boolean
   var negativeZero = R.either(R.equals(-0),
                               R.equals(new Number(-0)));  // jshint ignore:line
@@ -310,7 +325,7 @@
   //. > S.I('foo')
   //. "foo"
   //. ```
-  S.I = def('I', [a], function(x) { return x; });
+  var I = S.I = def('I', [a], function(x) { return x; });
 
   //# K :: a -> b -> a
   //.
@@ -324,7 +339,7 @@
   //. > R.map(S.K(42), R.range(0, 5))
   //. [42, 42, 42, 42, 42]
   //. ```
-  S.K = def('K', [a, b], function(x, y) {
+  var K = S.K = def('K', [a, b], function(x, y) {
     return x;
   });
 
@@ -586,7 +601,12 @@
   Nothing.prototype.chain = def('Nothing#chain', [Function], self);
 
   //  Nothing#concat :: Maybe a ~> Maybe a -> Maybe a
-  Nothing.prototype.concat = def('Nothing#concat', [Maybe], R.identity);
+  Nothing.prototype.concat = def('Nothing#concat', [Maybe], function(maybe) {
+    if (maybe instanceof Just) {
+      assertMethodExists('concat', maybe.value);
+    }
+    return maybe;
+  });
 
   //  Nothing#equals :: Maybe a ~> b -> Boolean
   Nothing.prototype.equals = def('Nothing#equals', [a], is(Nothing));
@@ -639,7 +659,13 @@
 
   //  Just#concat :: Maybe a ~> Maybe a -> Maybe a
   Just.prototype.concat = def('Just#concat', [Maybe], function(maybe) {
-    return maybe instanceof Just ? Just(this.value.concat(maybe.value)) : this;
+    assertMethodExists('concat', this.value);
+    if (maybe instanceof Just) {
+      assertMethodExists('concat', maybe.value);
+      return Just(this.value.concat(maybe.value));
+    } else {
+      return this;
+    }
   });
 
   //  Just#equals :: Maybe a ~> b -> Boolean
@@ -980,6 +1006,8 @@
 
   //  Left#concat :: Either a b ~> Either a b -> Either a b
   Left.prototype.concat = def('Left#concat', [Either], function(either) {
+    assertMethodExists('concat', this.value);
+    assertMethodExists('concat', either.value);
     return is(Left, either) ? Left(this.value.concat(either.value)) : either;
   });
 
@@ -1030,6 +1058,8 @@
 
   //  Right#concat :: Either a b ~> Either a b -> Either a b
   Right.prototype.concat = def('Right#concat', [Either], function(either) {
+    assertMethodExists('concat', this.value);
+    assertMethodExists('concat', either.value);
     return is(Right, either) ? Right(this.value.concat(either.value)) : this;
   });
 
@@ -1125,23 +1155,19 @@
 
   //. ### Control
 
-  var methodMissing = format('{repr} does not have {} {quote} method');
-
   //  toBoolean :: * -> Boolean
-  var toBoolean = function(x) {
-    if (is(Array, x))               return x.length > 0;
-    if (is(Boolean, x))             return x;
-    if (is(Function, x.toBoolean))  return x.toBoolean();
-    throw new TypeError(methodMissing([x, 'a', 'toBoolean']));
-  };
+  var toBoolean = R.cond([
+    [is(Array),   R.complement(R.isEmpty)],
+    [is(Boolean), I],
+    [R.T,         invoke('toBoolean', [])]
+  ]);
 
   //  empty :: a -> a
-  var empty = function(x) {
-    if (is(Array, x))               return [];
-    if (is(Boolean, x))             return false;
-    if (is(Function, x.empty))      return x.empty();
-    throw new TypeError(methodMissing([x, 'an', 'empty']));
-  };
+  var empty = R.cond([
+    [is(Array),   function() { return []; }],
+    [is(Boolean), K(false)],
+    [R.T,         invoke('empty', [])]
+  ]);
 
   //# and :: a -> a -> a
   //.
@@ -1653,7 +1679,7 @@
     return R.pipe(
       Just,
       R.filter(R.pipe(R.replace(/^[+-]/, ''),
-                      radix === 16 ? R.replace(/^0x/i, '') : R.identity,
+                      radix === 16 ? R.replace(/^0x/i, '') : I,
                       R.split(''),
                       R.all(R.pipe(R.toUpper,
                                    R.indexOf(_, charset),


Test patch:
diff --git a/test/index.js b/test/index.js
index a3e3db7..5f07d69 100644
--- a/test/index.js
+++ b/test/index.js
@@ -265,6 +265,10 @@ describe('maybe', function() {
                     errorEq(TypeError,
                             '‘Nothing#concat’ requires a value of type ' +
                             'Maybe as its first argument; received null'));
+
+      assert.throws(function() { S.Nothing().concat(S.Just(1)); },
+                    errorEq(TypeError,
+                            '1 does not have a ‘concat’ method'));
     });
 
     it('provides an "equals" method', function() {
@@ -478,6 +482,18 @@ describe('maybe', function() {
                     errorEq(TypeError,
                             '‘Just#concat’ requires a value of type Maybe ' +
                             'as its first argument; received [1, 2, 3]'));
+
+      assert.throws(function() { S.Just(1).concat(S.Just(0)); },
+                    errorEq(TypeError,
+                            '1 does not have a ‘concat’ method'));
+
+      assert.throws(function() { S.Just(2).concat(S.Just([1, 2, 3])); },
+                    errorEq(TypeError,
+                            '2 does not have a ‘concat’ method'));
+
+      assert.throws(function() { S.Just([1, 2, 3]).concat(S.Just(3)); },
+                    errorEq(TypeError,
+                            '3 does not have a ‘concat’ method'));
     });
 
     it('provides an "equals" method', function() {
@@ -897,6 +913,30 @@ describe('either', function() {
                     errorEq(TypeError,
                             '‘Left#concat’ requires a value of type Either ' +
                             'as its first argument; received [1, 2, 3]'));
+
+      assert.throws(function() { S.Left(1).concat(S.Left(0)); },
+                    errorEq(TypeError,
+                            '1 does not have a ‘concat’ method'));
+
+      assert.throws(function() { S.Left(2).concat(S.Right(0)); },
+                    errorEq(TypeError,
+                            '2 does not have a ‘concat’ method'));
+
+      assert.throws(function() { S.Left(3).concat(S.Left([1, 2, 3])); },
+                    errorEq(TypeError,
+                            '3 does not have a ‘concat’ method'));
+
+      assert.throws(function() { S.Left(4).concat(S.Right([1, 2, 3])); },
+                    errorEq(TypeError,
+                            '4 does not have a ‘concat’ method'));
+
+      assert.throws(function() { S.Left([1, 2, 3]).concat(S.Left(5)); },
+                    errorEq(TypeError,
+                            '5 does not have a ‘concat’ method'));
+
+      assert.throws(function() { S.Left([1, 2, 3]).concat(S.Right(6)); },
+                    errorEq(TypeError,
+                            '6 does not have a ‘concat’ method'));
     });
 
     it('provides an "equals" method', function() {
@@ -1080,6 +1120,30 @@ describe('either', function() {
                     errorEq(TypeError,
                             '‘Right#concat’ requires a value of type Either ' +
                             'as its first argument; received [1, 2, 3]'));
+
+      assert.throws(function() { S.Right(1).concat(S.Left(0)); },
+                    errorEq(TypeError,
+                            '1 does not have a ‘concat’ method'));
+
+      assert.throws(function() { S.Right(2).concat(S.Right(0)); },
+                    errorEq(TypeError,
+                            '2 does not have a ‘concat’ method'));
+
+      assert.throws(function() { S.Right(3).concat(S.Left([1, 2, 3])); },
+                    errorEq(TypeError,
+                            '3 does not have a ‘concat’ method'));
+
+      assert.throws(function() { S.Right(4).concat(S.Right([1, 2, 3])); },
+                    errorEq(TypeError,
+                            '4 does not have a ‘concat’ method'));
+
+      assert.throws(function() { S.Right([1, 2, 3]).concat(S.Left(5)); },
+                    errorEq(TypeError,
+                            '5 does not have a ‘concat’ method'));
+
+      assert.throws(function() { S.Right([1, 2, 3]).concat(S.Right(6)); },
+                    errorEq(TypeError,
+                            '6 does not have a ‘concat’ method'));
     });
 
     it('provides an "equals" method', function() {