Repository: sanctuary-js/sanctuary (js)

Issue:
Add an Equivalent Function to R.path
As discussed on Gitter, we should have a companion function to `S.prop`, which fulfils a similar function to [`R.path`](http://ramdajs.com/docs/#path). For internal consistency this function should be named `props`. This would be a good first contribution but if no one has picked it up over the next few days I will submit the PR.

Pull request description:
Add S.props
Closes #295. I wasn't sure about the text for the error message so I figured we could just bikeshed it in the PR here.

Patch:
diff --git a/index.js b/index.js
index 302c76e..97d792a 100644
--- a/index.js
+++ b/index.js
@@ -2787,6 +2787,29 @@
   }
   S.prop = def('prop', {a: [Accessible]}, [$.String, a, b], prop);
 
+  //# props :: Accessible a => Array String -> a -> b
+  //.
+  //. Takes a property path (an array of property names) and an object with
+  //. known structure and returns the value at the given path. If for some
+  //. reason the path does not exist, a type error is thrown.
+  //.
+  //. For accessing property paths of uncertain objects, use [`gets`](#gets)
+  //. instead.
+  //.
+  //. ```javascript
+  //. > S.props(['a', 'b', 'c'], {a: {b: {c: 1}}})
+  //. 1
+  //. ```
+  function props(path, obj) {
+    return path.reduce(function(memo, key) {
+      if (key in memo) return memo[key];
+      throw new TypeError('‘props’ expected object to have a property at ' +
+                          R.toString(path) + '; ' +
+                          R.toString(obj) + ' does not');
+    }, Object(obj));
+  }
+  S.props = def('props', {a: [Accessible]}, [$.Array($.String), a, b], props);
+
   //# get :: Accessible a => (b -> Boolean) -> String -> a -> Maybe c
   //.
   //. Takes a predicate, a property name, and an object and returns Just the
@@ -2814,10 +2837,9 @@
 
   //# gets :: Accessible a => (b -> Boolean) -> Array String -> a -> Maybe c
   //.
-  //. Takes a predicate, an array of property names, and an object and returns
-  //. Just the value at the path specified by the array of property names if
-  //. such a path exists and the value satisfies the given predicate; Nothing
-  //. otherwise.
+  //. Takes a predicate, a property path (an array of property names), and
+  //. an object and returns Just the value at the given path if such a path
+  //. exists and the value satisfies the given predicate; Nothing otherwise.
   //.
   //. See also [`get`](#get).
   //.


Test patch:
diff --git a/test/props.js b/test/props.js
new file mode 100644
index 0000000..7174765
--- /dev/null
+++ b/test/props.js
@@ -0,0 +1,70 @@
+'use strict';
+
+var S = require('..');
+
+var eq = require('./internal/eq');
+var throws = require('./internal/throws');
+
+
+test('props', function() {
+
+  eq(typeof S.props, 'function');
+  eq(S.props.length, 2);
+
+  throws(function() { S.props(1); },
+         TypeError,
+         'Invalid value\n' +
+         '\n' +
+         'props :: Accessible a => Array String -> a -> b\n' +
+         '                         ^^^^^^^^^^^^\n' +
+         '                              1\n' +
+         '\n' +
+         '1)  1 :: Number, FiniteNumber, NonZeroFiniteNumber, Integer, ValidNumber\n' +
+         '\n' +
+         'The value at position 1 is not a member of ‘Array String’.\n');
+
+  throws(function() { S.props(['a', 'b', 'c'], null); },
+         TypeError,
+         'Type-class constraint violation\n' +
+         '\n' +
+         'props :: Accessible a => Array String -> a -> b\n' +
+         '         ^^^^^^^^^^^^                    ^\n' +
+         '                                         1\n' +
+         '\n' +
+         '1)  null :: Null\n' +
+         '\n' +
+         '‘props’ requires ‘a’ to satisfy the Accessible type-class constraint; the value at position 1 does not.\n');
+
+  throws(function() { S.props([true, false], {a: {b: {c: 1}}}); },
+         TypeError,
+         'Invalid value\n' +
+         '\n' +
+         'props :: Accessible a => Array String -> a -> b\n' +
+         '                               ^^^^^^\n' +
+         '                                 1\n' +
+         '\n' +
+         '1)  true :: Boolean\n' +
+         '\n' +
+         'The value at position 1 is not a member of ‘String’.\n');
+
+  throws(function() { S.props(['a', 'b', 'c'], true); },
+         TypeError,
+         '‘props’ expected object to have a property at ["a", "b", "c"]; true does not');
+
+  throws(function() { S.props(['a', 'b', 'c'], 1); },
+         TypeError,
+         '‘props’ expected object to have a property at ["a", "b", "c"]; 1 does not');
+
+  throws(function() { S.props(['a', 'b', 'd'], {a: {b: {c: 1}}}); },
+         TypeError,
+         '‘props’ expected object to have a property at ["a", "b", "d"]; {"a": {"b": {"c": 1}}} does not');
+
+  throws(function() { S.props(['a', 'b', 'c'], [1, 2, 3]); },
+         TypeError,
+         '‘props’ expected object to have a property at ["a", "b", "c"]; [1, 2, 3] does not');
+
+  eq(S.props(['a', 'b', 'c'], {a: {b: {c: 1}}}), 1);
+  eq(S.props(['a', 'b', 'c', '0'], {a: {b: {c: [2, 4, 6]}}}), 2);
+  eq(S.props(['a', 'b', 'c'], Object.create({a: {b: {c: 1}}})), 1);
+
+});