Repository: ViacomInc/data-point (js)

Issue:
Add a factory for custom type check reducers
**Problem description**:

The API for custom type check reducers is a little awkward:

> To customize type checking you may use a Reducer. If the reducer throws an error, the type check fails

This adds extra boilerplate, and it can lead to inconsistent error messages, because each type check reducer could have a different message (whereas the built-in type checks always use a standard message, which makes it more obvious where the errors are coming from).

**Suggested solution**:

Expose a type check reducer factory, so that type check functions return a `boolean` and the factory takes care of generating the error message + throwing the error:

```js
// this function would also be used by
// the built-in type check functions, and
// the expectedType would be an optional param
function getTypeCheckErrorMessage (expectedType) { ... }

function createTypeCheckReducer (typeCheckFunction, getCustomMessage) {
  return function customTypeCheck (input) {
    // if it returns true, the type check passes
    if (typeCheckFunction(input)) {
      return input
    }

    // otherwise, we throw an error
    let message = getTypeCheckErrorMessage()
    if (getCustomMessage) {
      message += `\n${getCustomMessage(input)}`
    }

    throw new Error(message)
  }
}
```
The `typeCheckFunction` could also return an (optional) error message directly, instead of also having the `getCustomMessage` function (it would still be appended to the default message though). For instance, it might return an object:

```js
{
  didPass: false,
  message: 'custom message goes here'
}
```

This change would be backwards compatible, because reducers that throw errors would still work fine.






Pull request description:
feat(data-point): Add createTypeCheckReducer to public API


<!-- What changes are being made? (What feature/bug is being fixed here?) -->
**What**: Adds a `createTypeCheckReducer` higher order function

<!-- Why are these changes necessary? -->
**Why**: closes #247

<!-- Have you done all of these things?  -->
**Checklist**:
<!-- add "N/A" to the end of each line that's irrelevant to your changes -->
<!-- to check an item, place an "x" in the box like so: "- [x] Documentation" -->
- [ ] Has Breaking changes
- [x] Documentation
- [x] Tests
- [x] Ready to be merged

Patch:
diff --git a/packages/data-point/README.md b/packages/data-point/README.md
index 2ac04ab..ba859c5 100644
--- a/packages/data-point/README.md
+++ b/packages/data-point/README.md
@@ -1489,11 +1489,11 @@ All entities share a common API (except for [Reducer](#reducer-entity)).
 
 ##### <a name="entity-type-check">Entity type checking</a>
 
-You may use **inputType** and **outputType** to type check against the values being passed and resolved from an entity. Type checking does not mutate the result. 
+You may use **inputType** and **outputType** to type check the values being passed and returned from entities. Type checking does not mutate the result.
 
 **Built in type checks:**
 
-To use built-in type checks you may set the value of **inputType**/**outputType** to: `'string'`, `'number'`, `'boolean'`, `'function'`, `'error'`, `'array'`, or `'object'`. 
+To use built-in type checks, you may set the value of **inputType** or **outputType** to: `'string'`, `'number'`, `'boolean'`, `'function'`, `'error'`, `'array'`, or `'object'`.
 
 <details>
   <summary>Check if a model outputs an string</summary>
@@ -1523,45 +1523,46 @@ To use built-in type checks you may set the value of **inputType**/**outputType*
 
 </details>
 
-**Type Check using Reducer**
+**Custom type checking:**
 
-To customize type checking you may use a [Reducer](#reducers). If the reducer throws an error, the type check fails. In the case of `inputType` and `outputType` the return value is ignored.
+You may also type check with a [Schema Entity](#schema-entity), or by creating a [Reducer](#reducers) with the `createTypeCheckReducer` function.
 
+**SYNOPSIS**
+
+```js
+DataPoint.createTypeCheckReducer(typeCheckFunction, [expectedType])
+```
+
+**ARGUMENTS**
+
+| Argument | Type | Description |
+|:---|:---|:---|
+| *typeCheckFunction* | `Function<Boolean|String>` | Return `true` when the input is valid; otherwise, an error will be thrown. If the function returns a string, that will be appended to the error message. |
+| *expectedType* | `string` _(optional)_ | The expected type; this will also be used in the error message. |
 
 <details>
-  <summary>Custom type check with <a href="#function-reducer">FunctionReducer</a></summary>
+  <summary>Custom type check</a></summary>
 
   ```js
-  const dataPoint = DataPoint.create()
+  const DataPoint = require('data-point')
 
-  dataPoint.addEntities({
-    'model:getName': {
-      value: '$name',
+  const { createTypeCheckReducer } = DataPoint
 
-      outputType: value => {
-        if(typeof value === 'string' && value.length > 5) {
-          return trues
-        }
+  const isNonEmptyArray = input => Array.isArray(input) && input.length > 0
 
-        throw new Error('should be string and length > 5')
+  const dataPoint = DataPoint.create({
+    entities: {
+      'model:get-first-item': {
+        inputType: createTypeCheckReducer(isNonEmptyArray, 'non-empty-array'),
+        value: input => input[0]
       }
     }
   })
-
-  const input = {
-    name: 'DataPoint'
-  }
-
-  dataPoint.resolve('model:getName', input)
-    .then(output => {
-      // output -> 'DataPoint'
-    })
   ```
-
 </details>
 
 <details>
-  <summary>Custom type check with <a href="#entity-reducer">EntityReducer</a></summary>
+  <summary>Custom type check with a schema</summary>
 
   In this example we are using a [Schema Entity](#schema-entity) to check the inputType.
 
diff --git a/packages/data-point/lib/helpers/type-check-functions.js b/packages/data-point/lib/helpers/type-check-functions.js
index e7032bb..afd23a0 100644
--- a/packages/data-point/lib/helpers/type-check-functions.js
+++ b/packages/data-point/lib/helpers/type-check-functions.js
@@ -1,64 +1,69 @@
 const Util = require('util')
-const _ = require('lodash')
+const truncate = require('lodash/truncate')
+const isPlainObject = require('lodash/isPlainObject')
+
 const utils = require('../utils')
 
 /**
- * @param {*} value
- * @param {string} expectedType
- * @return {string}
+ * @param {Function} typeCheckFunction
+ * @param {String} expectedType
+ * @return {Function}
  */
-function getErrorMessage (value, expectedType) {
-  return Util.format(
-    'Entity type check failed!\nExpected type: %s\nActual type: %s\nInput value: %s',
-    expectedType,
-    utils.typeOf(value),
-    _.truncate(Util.inspect(value, { breakLength: Infinity }), {
-      length: 30
-    })
-  )
-}
-
-function isString (value) {
-  if (typeof value === 'string') return value
-  throw new Error(getErrorMessage(value, 'string'))
-}
-
-function isNumber (value) {
-  if (typeof value === 'number') return value
-  throw new Error(getErrorMessage(value, 'number'))
-}
+function createTypeCheckReducer (typeCheckFunction, expectedType) {
+  return function typeCheckReducer (value) {
+    const result = typeCheckFunction(value)
+    if (result === true) {
+      return value
+    }
 
-function isBoolean (value) {
-  if (typeof value === 'boolean') return value
-  throw new Error(getErrorMessage(value, 'boolean'))
-}
+    let message = getErrorMessage(value, expectedType)
+    if (typeof result === 'string') {
+      message += `\n${result}`
+    }
 
-function isFunction (value) {
-  if (typeof value === 'function') return value
-  throw new Error(getErrorMessage(value, 'function'))
+    throw new Error(message)
+  }
 }
 
-function isError (value) {
-  if (value instanceof Error) return value
-  throw new Error(getErrorMessage(value, 'function'))
-}
+module.exports.createTypeCheckReducer = createTypeCheckReducer
 
-function isArray (value) {
-  if (Array.isArray(value)) return value
-  throw new Error(getErrorMessage(value, 'array'))
-}
-
-function isObject (value) {
-  if (_.isPlainObject(value)) return value
-  throw new Error(getErrorMessage(value, 'object'))
+/**
+ * @param {*} value
+ * @param {String} expectedType
+ * @return {String}
+ */
+function getErrorMessage (value, expectedType) {
+  return `Entity type check failed!\n${
+    expectedType ? `Expected type: ${expectedType}\n` : ''
+  }Actual type: ${utils.typeOf(value)}\nInput value: ${truncate(
+    Util.inspect(value, { breakLength: Infinity }),
+    {
+      length: 30
+    }
+  )}`
 }
 
 module.exports = {
-  isString,
-  isNumber,
-  isBoolean,
-  isFunction,
-  isError,
-  isArray,
-  isObject
+  createTypeCheckReducer,
+  defaults: {
+    isString: createTypeCheckReducer(
+      value => typeof value === 'string',
+      'string'
+    ),
+    isNumber: createTypeCheckReducer(
+      value => typeof value === 'number',
+      'number'
+    ),
+    isBoolean: createTypeCheckReducer(
+      value => typeof value === 'boolean',
+      'boolean'
+    ),
+    isFunction: createTypeCheckReducer(
+      value => typeof value === 'function',
+      'function'
+    ),
+    isError: createTypeCheckReducer(value => value instanceof Error, 'error'),
+    isArray: createTypeCheckReducer(value => Array.isArray(value), 'array'),
+    isObject: createTypeCheckReducer(value => isPlainObject(value), 'object')
+  }
 }
diff --git a/packages/data-point/lib/helpers/type-check-helpers.js b/packages/data-point/lib/helpers/type-check-helpers.js
index d4efcfb..7bb04de 100644
--- a/packages/data-point/lib/helpers/type-check-helpers.js
+++ b/packages/data-point/lib/helpers/type-check-helpers.js
@@ -1,6 +1,7 @@
-const typeCheckFunctions = require('./type-check-functions')
+const typeCheckFunctions = require('./type-check-functions').defaults
 
 const modifiers = {
+  __proto__: null,
   string: typeCheckFunctions.isString,
   number: typeCheckFunctions.isNumber,
   boolean: typeCheckFunctions.isBoolean,
diff --git a/packages/data-point/lib/index.js b/packages/data-point/lib/index.js
index 87eb77e..f648827 100644
--- a/packages/data-point/lib/index.js
+++ b/packages/data-point/lib/index.js
@@ -2,6 +2,7 @@
 
 const core = require('./core')
 const helpers = require('./helpers')
+const { createTypeCheckReducer } = require('./helpers/type-check-functions')
 
 module.exports = {
   create: core.create,
@@ -12,5 +13,6 @@ module.exports = {
   reducify: helpers.reducify,
   reducifyAll: helpers.reducifyAll,
   createReducer: helpers.createReducer,
-  createReducerResolver: helpers.reducifyAll
+  createReducerResolver: helpers.reducifyAll,
+  createTypeCheckReducer
 }


Test patch:
diff --git a/packages/data-point/lib/__snapshots__/index.test.js.snap b/packages/data-point/lib/__snapshots__/index.test.js.snap
index 4a404d7..0faeae3 100644
--- a/packages/data-point/lib/__snapshots__/index.test.js.snap
+++ b/packages/data-point/lib/__snapshots__/index.test.js.snap
@@ -6,6 +6,7 @@ Object {
   "createEntity": [Function],
   "createReducer": [Function],
   "createReducerResolver": [Function],
+  "createTypeCheckReducer": [Function],
   "entityFactories": Object {
     "Collection": [Function],
     "Control": [Function],
diff --git a/packages/data-point/lib/helpers/__snapshots__/type-check-functions.test.js.snap b/packages/data-point/lib/helpers/__snapshots__/type-check-functions.test.js.snap
index f8d7bc1..6b524fe 100644
--- a/packages/data-point/lib/helpers/__snapshots__/type-check-functions.test.js.snap
+++ b/packages/data-point/lib/helpers/__snapshots__/type-check-functions.test.js.snap
@@ -1,5 +1,25 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
+exports[`custom type check reducer should throw error (with custom message) 1`] = `
+"Entity type check failed!
+Actual type: boolean
+Input value: false
+Hello the world is collapsing."
+`;
+
+exports[`custom type check reducer should throw error (with expected type) 1`] = `
+"Entity type check failed!
+Expected type: non-empty array
+Actual type: boolean
+Input value: false"
+`;
+
+exports[`custom type check reducer should throw error for invalid input 1`] = `
+"Entity type check failed!
+Actual type: boolean
+Input value: false"
+`;
+
 exports[`isArray It should throw error when not matched 1`] = `
 "Entity type check failed!
 Expected type: array
@@ -23,7 +43,7 @@ Input value: 'foo'"
 
 exports[`isError It should throw error when not matched 1`] = `
 "Entity type check failed!
-Expected type: function
+Expected type: error
 Actual type: string
 Input value: 'foo'"
 `;
diff --git a/packages/data-point/lib/helpers/type-check-functions.test.js b/packages/data-point/lib/helpers/type-check-functions.test.js
index d354df2..a82dd7f 100644
--- a/packages/data-point/lib/helpers/type-check-functions.test.js
+++ b/packages/data-point/lib/helpers/type-check-functions.test.js
@@ -8,9 +8,11 @@ const {
   isError,
   isArray,
   isObject
-} = require('./type-check-functions')
+} = require('./type-check-functions').defaults
 
-function testTypeChecker (typeCheck, goodValue, badValue, type) {
+const { createTypeCheckReducer } = require('./type-check-functions')
+
+function testTypeChecker (typeCheck, goodValue, badValue) {
   test('It should return true if type match', () => {
     expect(typeCheck(goodValue)).toBe(goodValue)
   })
@@ -22,31 +24,63 @@ function testTypeChecker (typeCheck, goodValue, badValue, type) {
 }
 
 describe('isString', () => {
-  testTypeChecker(isString, 'foo', 1, 'string')
+  testTypeChecker(isString, 'foo', 1)
 })
 
 describe('isNumber', () => {
-  testTypeChecker(isNumber, 1, 'foo', 'number')
+  testTypeChecker(isNumber, 1, 'foo')
 })
 
 describe('isBoolean', () => {
-  testTypeChecker(isBoolean, true, 'foo', 'boolean')
-  testTypeChecker(isBoolean, false, 'foo', 'boolean')
+  testTypeChecker(isBoolean, true, 'foo')
+  testTypeChecker(isBoolean, false, 'foo')
 })
 
 describe('isFunction', () => {
   const fn = () => {}
-  testTypeChecker(isFunction, fn, 'foo', 'function')
+  testTypeChecker(isFunction, fn, 'foo')
 })
 
 describe('isError', () => {
-  testTypeChecker(isError, new Error(), 'foo', 'Error')
+  testTypeChecker(isError, new Error(), 'foo')
 })
 
 describe('isArray', () => {
-  testTypeChecker(isArray, [], 'foo', 'Array')
+  testTypeChecker(isArray, [], 'foo')
 })
 
 describe('isObject', () => {
-  testTypeChecker(isObject, {}, [], 'Object')
+  testTypeChecker(isObject, {}, [])
+})
+
+describe('custom type check reducer', () => {
+  const expectedType = 'non-empty array'
+
+  const check = input => {
+    return Array.isArray(input) && !!input.length
+  }
+
+  const checkWithCustomMessage = input => {
+    return check(input) || 'Hello the world is collapsing.'
+  }
+
+  it("should return the input when it's valid", () => {
+    const reducer = createTypeCheckReducer(check, expectedType)
+    expect(reducer([true])).toEqual([true])
+  })
+
+  it('should throw error for invalid input', () => {
+    const reducer = createTypeCheckReducer(check)
+    expect(() => reducer(false)).toThrowErrorMatchingSnapshot()
+  })
+
+  it('should throw error (with expected type)', () => {
+    const reducer = createTypeCheckReducer(check, expectedType)
+    expect(() => reducer(false)).toThrowErrorMatchingSnapshot()
+  })
+
+  it('should throw error (with custom message)', () => {
+    const reducer = createTypeCheckReducer(checkWithCustomMessage)
+    expect(() => reducer(false)).toThrowErrorMatchingSnapshot()
+  })
 })
diff --git a/packages/data-point/lib/helpers/type-check-helpers.test.js b/packages/data-point/lib/helpers/type-check-helpers.test.js
index fc14138..5e8d83b 100644
--- a/packages/data-point/lib/helpers/type-check-helpers.test.js
+++ b/packages/data-point/lib/helpers/type-check-helpers.test.js
@@ -1,7 +1,7 @@
 /* eslint-env jest */
 
 const helpers = require('./type-check-helpers')
-const typeCheckFunctions = require('./type-check-functions')
+const typeCheckFunctions = require('./type-check-functions').defaults
 
 describe('type-check-helpers#normalizeTypeCheckSource', () => {
   test('It should return function reducer it matches any of the predefined types', () => {