Repository: sanctuary-js/sanctuary (js)

Issue:
bring your own environment
__Problem:__

```javascript
S.I(['foo', 42]);
// ! TypeError: Type-variable constraint violation
//
//   I :: a -> a
//        ^
//        1
//
//   1)  ["foo", 42] :: Array ???
//
//   Since there is no type of which all the above values are members, the type-variable constraint has been violated.
```

If this is a case of array-as-list, a type error is appropriate. But perhaps it's a case of array-as-tuple?

sanctuary-def could—and should—provide `Pair a b` to support two-element arrays. The fact remains, though, that Sanctuary's environment cannot possibly include every type, as there are infinitely many.

__Solution:__

The user must have the ability to provide her own environment.

"Initializing" Sanctuary is currently done in one of two ways:

```javascript
const S = require('sanctuary');
```

```javascript
const S = require('sanctuary').unchecked;
```

If the user is to provide an environment, it makes sense to take `(checkTypes :: Boolean, env :: [Type])` as [`$.create`][1] does:

```javascript
const sanctuary = require('sanctuary');

const S = sanctuary.create(true, sanctuary.env);
```

```javascript
const sanctuary = require('sanctuary');

const S = sanctuary.create(false, sanctuary.env);
```

When @Avaq and I discussed this on Gitter, he suggested having `const S = require('sanctuary');` continue to work as it does today. This strikes me as a great idea, as it would allow new users to have a positive first experience with the library.


[1]: https://github.com/plaid/sanctuary-def/blob/v0.5.0/index.js#L1002


Pull request description:
add S.create for creating Sanctuary modules with custom environments
Closes #188

I'd like to receive feedback from at least two people before merging this pull request, as this is a significant API change.


Patch:
diff --git a/index.js b/index.js
index 0603507..dd3c086 100644
--- a/index.js
+++ b/index.js
@@ -128,21 +128,17 @@
 //.
 //. There is a performance cost to run-time type checking. One may wish to
 //. disable type checking in certain contexts to avoid paying this cost.
-//. There are actually two versions of the Sanctuary module: one with type
-//. checking; one without. The latter is accessible via the `unchecked`
-//. property of the former.
+//. [`create`](#create) facilitates the creation of a Sanctuary module which
+//. does not perform type checking.
 //.
-//. When application of `S.unchecked.<name>` honours the function's type
-//. signature the result will be the same as if `S.<name>` had been used
-//. instead. Otherwise, the behaviour is unspecified.
-//.
-//. In Node, one could use an environment variable to determine which version
-//. of the Sanctuary module to use:
+//. In Node, one could use an environment variable to determine whether to
+//. perform type checking:
 //.
 //. ```javascript
-//. const S = process.env.NODE_ENV === 'production' ?
-//.             require('sanctuary').unchecked :
-//.             require('sanctuary');
+//. const sanctuary = require('sanctuary');
+//.
+//. const checkTypes = process.env.NODE_ENV !== 'production';
+//. const S = sanctuary.create({checkTypes: checkTypes, env: sanctuary.env});
 //. ```
 //.
 //. ## API
@@ -319,8 +315,8 @@
     }
   );
 
-  //  env :: Array Type
-  var env = $.env.concat([
+  //  defaultEnv :: Array Type
+  var defaultEnv = $.env.concat([
     $.FiniteNumber,
     $.NonZeroFiniteNumber,
     $Either,
@@ -333,14 +329,79 @@
     $.ValidNumber
   ]);
 
-  //  createSanctuary :: Boolean -> Module
-  var createSanctuary = function(checkTypes) {
+  //  Options :: Type
+  var Options = $.RecordType({checkTypes: $.Boolean, env: $.Array($.Any)});
+
+  //  createSanctuary :: Options -> Module
+  var createSanctuary = function createSanctuary(opts) {
 
   /* eslint-disable indent */
 
   var S = {EitherType: $Either, MaybeType: $Maybe};
 
-  var def = $.create({checkTypes: checkTypes, env: env});
+  //# create :: { checkTypes :: Boolean, env :: Array Type } -> Module
+  //.
+  //. Takes an options record and returns a Sanctuary module. `checkTypes`
+  //. specifies whether to enable type checking. The module's polymorphic
+  //. functions (such as [`I`](#I)) require each value associated with a
+  //. type variable to be a member of at least one type in the environment.
+  //.
+  //. A well-typed application of a Sanctuary function will produce the same
+  //. result regardless of whether type checking is enabled. If type checking
+  //. is enabled, a badly typed application will produce an exception with a
+  //. descriptive error message.
+  //.
+  //. The following snippet demonstrates defining a custom type and using
+  //. `create` to produce a Sanctuary module which is aware of that type:
+  //.
+  //. ```javascript
+  //. const {create, env} = require('sanctuary');
+  //. const $ = require('sanctuary-def');
+  //.
+  //. //    identityTypeName :: String
+  //. const identityTypeName = 'my-package/Identity';
+  //.
+  //. //    Identity :: a -> Identity a
+  //. const Identity = function Identity(x) {
+  //.   return {
+  //.     '@@type': identityTypeName,
+  //.     map: f => Identity(f(x)),
+  //.     chain: f => f(x),
+  //.     // ...
+  //.     value: x,
+  //.   };
+  //. };
+  //.
+  //. //    isIdentity :: a -> Boolean
+  //. const isIdentity = x => x != null && x['@@type'] === identityTypeName;
+  //.
+  //. //    identityToArray :: Identity a -> Array a
+  //. const identityToArray = identity => [identity.value];
+  //.
+  //. //    IdentityType :: Type
+  //. const IdentityType =
+  //. $.UnaryType(identityTypeName, isIdentity, identityToArray);
+  //.
+  //. const S = create({
+  //.   checkTypes: process.env.NODE_ENV !== 'production',
+  //.   env: env.concat([IdentityType]),
+  //. });
+  //. ```
+  //.
+  //. See also [`env`](#env).
+  S.create =
+  $.create({checkTypes: opts.checkTypes, env: defaultEnv})('create',
+                                                           {},
+                                                           [Options, $.Object],
+                                                           createSanctuary);
+
+  //# env :: Array Type
+  //.
+  //. The default environment, which may be used as is or as the basis of a
+  //. custom environment in conjunction with [`create`](#create).
+  S.env = defaultEnv;
+
+  var def = $.create(opts);
 
   //  Note: Type checking of method arguments takes place once all arguments
   //  have been provided (whereas function arguments are checked as early as
@@ -3446,11 +3507,7 @@
 
   };
 
-  //  Export two versions of the Sanctuary module: one with type checking;
-  //  one without.
-  var S       = createSanctuary(true);
-  S.unchecked = createSanctuary(false);
-  return S;
+  return createSanctuary({checkTypes: true, env: defaultEnv});
 
 }));
 


Test patch:
diff --git a/test/create.js b/test/create.js
new file mode 100644
index 0000000..a2b1a28
--- /dev/null
+++ b/test/create.js
@@ -0,0 +1,83 @@
+'use strict';
+
+var throws = require('assert').throws;
+
+var $ = require('sanctuary-def');
+
+var eq = require('./utils').eq;
+var errorEq = require('./utils').errorEq;
+var S = require('..');
+
+
+//  customEnv :: Array Type
+var customEnv = S.env.concat([$.EnumType(['foo', true, 42])]);
+
+var checkedDefaultEnv   = S.create({checkTypes: true, env: S.env});
+var checkedCustomEnv    = S.create({checkTypes: true, env: customEnv});
+var uncheckedDefaultEnv = S.create({checkTypes: false, env: S.env});
+var uncheckedCustomEnv  = S.create({checkTypes: false, env: customEnv});
+
+
+describe('create', function() {
+
+  it('is a unary function', function() {
+    eq(typeof S.create, 'function');
+    eq(S.create.length, 1);
+  });
+
+  it('type checks its arguments', function() {
+    throws(function() { S.create({}); },
+           errorEq(TypeError,
+                   'Invalid value\n' +
+                   '\n' +
+                   'create :: { checkTypes :: Boolean, env :: Array Any } -> Object\n' +
+                   '          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n' +
+                   '                               1\n' +
+                   '\n' +
+                   '1)  {} :: Object, StrMap ???\n' +
+                   '\n' +
+                   'The value at position 1 is not a member of ‘{ checkTypes :: Boolean, env :: Array Any }’.\n'));
+
+    throws(function() { S.create({checkTypes: 'true', env: []}); },
+           errorEq(TypeError,
+                   'Invalid value\n' +
+                   '\n' +
+                   'create :: { checkTypes :: Boolean, env :: Array Any } -> Object\n' +
+                   '                          ^^^^^^^\n' +
+                   '                             1\n' +
+                   '\n' +
+                   '1)  "true" :: String\n' +
+                   '\n' +
+                   'The value at position 1 is not a member of ‘Boolean’.\n'));
+  });
+
+  it('returns a Sanctuary module', function() {
+    var expected = Object.keys(S).sort();
+    eq(Object.keys(checkedDefaultEnv).sort(), expected);
+    eq(Object.keys(checkedCustomEnv).sort(), expected);
+    eq(Object.keys(uncheckedDefaultEnv).sort(), expected);
+    eq(Object.keys(uncheckedCustomEnv).sort(), expected);
+  });
+
+  it('can create a module which does not perform type checking', function() {
+    eq(uncheckedDefaultEnv.inc(42), S.inc(42));
+    eq(uncheckedDefaultEnv.inc('XXX'), 'XXX1');
+  });
+
+  it('can create a module with a custom environment', function() {
+    throws(function() { S.I(['foo', 'foo', 42]); },
+           errorEq(TypeError,
+                   'Type-variable constraint violation\n' +
+                   '\n' +
+                   'I :: a -> a\n' +
+                   '     ^\n' +
+                   '     1\n' +
+                   '\n' +
+                   '1)  ["foo", "foo", 42] :: Array ???\n' +
+                   '\n' +
+                   'Since there is no type of which all the above values are members, the type-variable constraint has been violated.\n'));
+
+    eq(checkedCustomEnv.I(['foo', 'foo', 42]), ['foo', 'foo', 42]);
+  });
+
+});
diff --git a/test/unchecked.js b/test/unchecked.js
deleted file mode 100644
index 83909d7..0000000
--- a/test/unchecked.js
+++ /dev/null
@@ -1,20 +0,0 @@
-'use strict';
-
-var R = require('ramda');
-
-var eq = require('./utils').eq;
-var S = require('..');
-
-describe('unchecked', function() {
-
-  it('has the same properties as the top-level module', function() {
-    eq(R.sortBy(S.I, R.keys(S.unchecked)),
-       R.sortBy(S.I, R.without(['unchecked'], R.keys(S))));
-  });
-
-  it('provides functions which do not perform type checking', function() {
-    eq(S.unchecked.inc(42), S.inc(42));
-    eq(S.unchecked.inc('XXX'), 'XXX1');
-  });
-
-});