sanctuary-js / sanctuary-def

Run-time type system for JavaScript
MIT License
294 stars 23 forks source link

Ideas about `env`. #74

Open Avaq opened 8 years ago

Avaq commented 8 years ago

Some ideas I've been meaning to put on paper screen.


Definition of environment

const $ = require('sanctuary-env');

//A standard environment, contains type definitions
//for JavaScript natives and common types like Integer.
$.env

//We can extend the environment. Every time we do,
//a new environment is created.
.add($Either)

//We can also concatenate two environments, creating
//an environment containing the types of both.
//(note: env1.strict && env2.strict, so loosy is viral?)
.concat(S.env)
.concat($.Env([MyType]))

//We can create a loosy version of the environment,
//disabling type-checking in favour of performance.
.loosy()
.strict()

Creation of functions

Functions are defined through the def function. It takes an environment, function name, constraints, type definition and finally, a function (body). It will return a function that is curried, and uses the environment for its type-checking.

const add = $.def(
  $.env, 'add',
  {}, [$.Number, $.Number, $.Number],
  (a, b) => a + b
);

In most cases, we could partially apply $.def with our environment.

const def = $.def($.env);

This approach allows a user to build an environment first, allowing all sanctuary-def-based libraries to type-check the entire range of types a user uses:

const $ = require('sanctuary-env');
const core = require('sanctuary');
const string = require('sanctuary-string');
const number = require('sanctuary-number');

const env = $.env
            .add(MyType)
            .add(MyOtherType)
            .concat(core.env)
            .concat(string.env)
            .concat(number.env)

const S = Object.assign(core.create(env), {
  string: string.create(env),
  number: number.create(env)
});

Sanctuary could have a utility that does this for you:

const S = require('sanctuary')
          .createWithPlugins(['string', 'number']);

Adding polymorhpism

Since sanctuary-env and fantasy-environment/bilby have very similar solutions to different problems, I thought it might be nice to combine them.

This would mean sanctuary-env created functions would not only be curried and type-checked, they would also be polymorphic.

The downside is that it will become quite difficult to keep the API as described above. Every time a function is created that shares a name with one created earlier, a function that represents both should be created.

This means def would have to have access to earlier functions somehow. Fantasy Land solves it by returning a new environment that contains the function, rather than returning the actual function. It pays to move the env parameter to the end of def if we take this approach:

const defineFunctions = pipe([
  def('negate', {}, [$.Number, $.Number], x => -x),
  def('negate', {}, [$.Boolean, $.Boolean], x => !x),
  ...
])

const S = defineFunctions(S.env);

S.negate(true) === false;
S.negate(42) === -42;

In this case, the returned object is the environment. It remains useful to allow users to create the environment in two steps; first types, then functions. The API for the user hasn't changed, but the way we define our functions internally has changed completely.

Users are now able to do the following:

const env = $.env.concat(myEnv);

const S = pipe([
  require('sanctuary').create,
  $.def('add', {}, [MyType, MyType, MyType], (a, b) => a.mergeSpectacularlyWith(b))
], env);

Note One possible degradation is the loosy feature. How do we do the polymorphism without checking the input types? Perhaps this kind of feature is best kept for when the performance is no longer a concern.

davidchambers commented 8 years ago

Very interesting!

Here are some comments and questions:

Avaq commented 8 years ago

We could choose to make it an error to concatenate a strict environment and a loose one

That's clearly the nicer way! No surprises, and it takes very little effort to adjust it explicitly:

$.env.loosy().concat(myEnv.loosy())

Do you mean concat rather than contact in the first code block?

Whoops. Fixed :D

Are 'string' and 'number' special values?

Nah, they're just the names of the sanctuary packages to load, without the sanctuary- prefix. I just wanted to show the boilerplate can be reduced, specifically how is not that important. I guess createWithPlugins([require('sanctuary-string'), require('sanctuary-number')]) would've been clearer.

I'm not sure about bilby-style polymorphism.

Me neither. But it's an idea I thought worth sharing. You bring up some good points against its employment. The nice thing is that the API for the end-user wouldn't have to change if we do decide to add bilby polymorphism at some point.

davidchambers commented 8 years ago

I guess createWithPlugins([require('sanctuary-string'), require('sanctuary-number')]) would've been clearer.

I'd prefer this approach. If we're to support "plug-ins" I'd like to provide a level playing field rather than privilege sanctuary-* packages.

I'm not sure about bilby-style polymorphism.

Me neither. But it's an idea I thought worth sharing.

Absolutely! Thank you for doing so. I'll give myself some time to mull it over.

rjmk commented 8 years ago

I really love this thread! I thought I'd chime in with a couple of thoughts as well.

API Thoughts

Sorry about the big header above. I just feel bad if I break spec and don't start with an h1.

I think there are 2 sensible approaches to think about this. "Doing one thing well" and how people will use the API.

One thing well

Sanctuary is the culmination of 4 things:

  1. A runtime type checking module (sanctuary-def)
  2. A collection of type definitions for ordinary JS
  3. A collection of useful ADTs defined for JS
  4. A collection of utility functions for JS making use of the above 3 things

sanctuary-def is the relevant part for API tweaking (as it appears others have twigged before me, considering the placement of this issue).

Use cases

On use cases, I think there are 3 that need to be considered.

a) A user wants Sanctuary to reason with more types in their application b) A user want to add runtime type checking in their module c) A user wants to use Sanctuary functions in their module

I think we've already thought about some of the issues with (a), but I at least haven't thought as much about the (b) & (c) cases. Also, modules built with (b) & (c) will have a-c for themselves. Similarly, the ability to disable typechecking will need to be dealt with by modules consuming sanctuary-def and sanctuary.

Waiting for def

My favourite API to date has been functions waiting for def from $.create. That means that there's no dependency on sanctuary-def. It also means its trivial to allow the consumer to control the type checking (you can just pass in a def that throws away the first 3 arguments and passes the implementation through transparently). It's a bit of a shame that you have to think in terms of defining functions rather than type environments; I think the latter is slightly more intuitive.

This API gives a natural decomposition of a project using sanctuary-def into 3 parts: a module of needed types, a module of functions waiting for a def. And then a 'batteries included' module. I think there's a problem or two with this.

Issues

The first is that there's a bit of a weird dependence on which types make its way into the module of needed types. The tail is wagging the dog. I think if there're not too many barriers to making many small type modules and combining them, that's not a big issue.

The second is that this is a relatively large amount of overhead for a small typed module. I don't think there's anything wrong with having to extract out the types (though they could just be exported on the library). Perhaps a bigger concern is that it may lead to people exporting their modules with 'types sealed in' (like in Sanctuary currently). I think we should make it as easy as possible to let people do 'the right thing'.

Function-level responsibility

@Avaq's suggestions have a lot to recommend them (and I'm fond of waiting for def), but I can think of one or two other APIs that might be worth mentioning. Specifically, they're based around the idea that the ability to extend the type environment can be given to the function itself, rather than to the module and wrapping which creates it. The suggestions where each function still needs an env / def are arguably cases of this, but here are some ideas that allow a minimum environment to already be available (I worry in the other cases that there may be fake decoupling which just puts a lot of the burden on how to fit everything together into the memory of the programmer).

Functions created by sanctuary-def could have a concatEnv method

This is kind of gross and OOP-y, but I don't think it would be too tough to implement. The method would return a new function. This has the disadvantage of disabling .apply optimisation throughout the codebase in V8. I'm not sure we should reject this function because of that though. What's fast in V8 can change fairly quickly

A function on functions created by sanctuary-def that returns the original function and environment

This is conceptually pretty clean, but tougher to implement. Adding a property to mark out the functions has the V8 problem and is a bit of a hack (trying to stick some type safety in without a tool like Sanctuary). Another would be to have a special argument or a final parameter for every function that got defined; when supplied it would have the necessary results. I think this is ghastly; function signatures are easy to contort in JS so they spring to mind as solutions when its rather inappropriate.

def returns a special type that needs a trivial 'trigger pull' to be finalised

One example would be to return functions that when provided an environment return another function waiting for an environment. If they're called without an argument then they are now ready to be used. This allows people to provide a default environment and have a low cost finalisation process. Of course, rather than overloading the function, the best thing to do would be to return a custom type with 2 methods. Say the type is called Q, it would have :: Q ~> Env -> Q and :: Q ~> () -> Function.

Closing thoughts

I still think accepting def is best. I think it's the best conceptual separation and I like the way it handles type checking. After that, I think I like the custom type, but that could be provided as a helper module for working with the def-accepting API. The downside is I think that we would need to make an effort to support using and creating Sanctuary ecosphere modules with good APIs. I have a feeling there may be a superior option that someone's going to come up with that doesn't have this flaw, but has the other advantages of waiting for def.

davidchambers commented 8 years ago

Great comment. I've read it twice but feel I need to sketch the different options in order to get a better sense of how they would affect users.

It also means its trivial to allow the consumer to control the type checking (you can just pass in a def that throws away the first 3 arguments and passes the implementation through transparently).

I loved this when I first read it, but I spotted a problem upon second reading: def is responsible for currying as well as type checking. Complecting these two concerns feels wrong, but it's necessary if we wish to perform type checking as arguments are provided rather than when the last argument is provided (so that S.add('XXX') will raise an exception rather than return a function which raises an exception when applied).

rjmk commented 8 years ago

Great catch! I guess there are 2 responses. The first is a user providing a function that throws away the first 3 arguments and curries the implementation is still fairly low overhead and lets type checking be turned off. The other is that we could go Purescript-FFI and define the functions as manually curried. I'm not sure what would go in to having the type checking percolate recursively to the resulting functions though

davidchambers commented 8 years ago

a user [could provide] a function that throws away the first 3 arguments and curries the implementation

Currently one could provide R.curry, but ramda/ramda#1843 will remove placeholder support so it will not not remain equivalent to def with type checking disabled for much longer. Furthermore, we intend to make placeholders cyclical (#73) so the behaviour will diverge irrespective of the Ramda pull request.

we could go Purescript-FFI and define the functions as manually curried

Wouldn't this mean that if the user-provided function were to return the implementation untouched, f(x)(y) would work but not f(x, y)?

It seems that what would be ideal is a function equivalent to sanctuary-def's internal curry function in every way but one: it would not perform type checking. We almost have such a function, as it turns out, since all the type-checking code is guarded by if (checkTypes) { … }.

Currently checkTypes is defined in the closure created by $.create(opts), where the type of opts is { checkTypes :: Boolean, env :: Array Type }. All functions defined via the resulting def function share opts. This is inflexible. Rather than take the options as early as possible, we could take them as late as possible: def could return a function which takes opts and returns the appropriately wrapped implementation. $.create would no longer be necessary since we could provide $.def:

type Options = { checkTypes :: Boolean, env :: Array Type }

$.def :: String -> StrMap (Array TypeClass) -> Array Type -> AnyFunction -> Options -> AnyFunction

Before:

const def = $.create({checkTypes: …, env: …});

//    add :: Number -> Number -> Number
const add =
def('add',
    {},
    [$.Number, $.Number, $.Number],
    (x, y) => x + y);

After:

//    add_ :: Options -> Number -> Number -> Number
const add_ =
$.def('add',
      {},
      [$.Number, $.Number, $.Number],
      (x, y) => x + y);

//    add :: Number -> Number -> Number
const add = add_({checkTypes: …, env: …});

How would this affect Sanctuary? The API needn't change at all. We could continue to provide S.create. The internals would change slightly: we'd provide the options given to us each time we call $.def.

We could do away with S.create and simply provide functions awaiting options in addition to their normal arguments, but writing S.add(opts, 2, 2) wouldn't feel good. Perhaps, though, we could provide a function which takes options and provides a Sanctuary module with the options bound to each function:

const S = require('sanctuary');

//    env :: Array Type
const env = …;

//    opts :: { checkTypes :: Boolean, env :: Array Type }
const opts = {checkTypes: …, env: …}

S.add_(opts, 2, 2);  // => 4

S.withOptions({checkTypes: true, env: env}, S => {
  S.add(2, 2);  // => 4
  S.add('foo', 'bar');  // ! TypeError
  // ...
});

S.withOptions({checkTypes: false, env: env}, S => {
  S.add(2, 2);  // => 4
  S.add('foo', 'bar');  // => 'foobar'
  // ...
});

// We could even support this by having default options (as we currently do).
S.add(2, 2);  // => 4

I'm not sure this is any better than our current solution (S.create).

I don't know whether I've proposed something useful or whether I've walked in a complete circle. What exactly is the problem we'd like to solve?

rjmk commented 8 years ago

I think for me the answer is still to take def, but expect people for now to use $.create to create a def function with typechecking off

I don't have much time now, but I'll try and justify that this weekend

rjmk commented 8 years ago

Sorry for the delay!

My reasons for accepting def are above and in the link in that comment. To summarise, I feel it cleanly separates the responsibilities of the 2 modules.

I agree that it's a good idea not to change Sanctuary's API. I do think it's a good idea to extend it, because I don't feel very comfortable using Sanctuary.create, especially in a context of extending Sanctuary. Having a version of the library that's still "open" to the def functions provides an API that I think is cleaner.

davidchambers commented 8 years ago

I'd very much appreciate concrete examples to discuss. I'd like to see how advanced Sanctuary usage would change (I believe we're all in favour of require('sanctuary') continuing to provide ready-to-use versions of the various functions). It seems that rather than use S.create one would use $.create to define def, then map over the "unbound" Sanctuary module, applying each unbound function to def to produce a curried, possibly type-checked function.

Basic usage, before:

const S = require('sanctuary');

Basic usage, after:

const S = require('sanctuary');

Advanced usage, before:

const envvar = require('envvar');
const sanctuary = require('sanctuary');

const Bar = require('./types/Bar');
const Foo = require('./types/Foo');

const SANCTUARY_CHECK_TYPES = envvar.boolean('SANCTUARY_CHECK_TYPES');

const S = sanctuary.create({
  checkTypes: SANCTUARY_CHECK_TYPES,
  env: sanctuary.env.concat([Bar, Foo]),
});

Advanced usage, after:

const envvar = require('envvar');
const R = require('ramda');
const sanctuary = require('sanctuary');
const $ = require('sanctuary-def');

const Bar = require('./types/Bar');
const Foo = require('./types/Foo');

const SANCTUARY_CHECK_TYPES = envvar.boolean('SANCTUARY_CHECK_TYPES');

const def = $.create({
  checkTypes: SANCTUARY_CHECK_TYPES,
  env: sanctuary.env.concat([Bar, Foo]),
});

const S = R.map(f => f(def), sanctuary.unbound);

Diff:

 const envvar = require('envvar');
+const R = require('ramda');
 const sanctuary = require('sanctuary');
+const $ = require('sanctuary-def');

 const Bar = require('./types/Bar');
 const Foo = require('./types/Foo');

 const SANCTUARY_CHECK_TYPES = envvar.boolean('SANCTUARY_CHECK_TYPES');

-const S = sanctuary.create({
+const def = $.create({
   checkTypes: SANCTUARY_CHECK_TYPES,
   env: sanctuary.env.concat([Bar, Foo]),
 });
+
+const S = R.map(f => f(def), sanctuary.unbound);

Is this what you have in mind, @rjmk?

rjmk commented 8 years ago

I'd very much appreciate concrete examples to discuss

Yeah, that's a great idea.

You're right about my imagined usage. I'd also like to like at some other use cases.

After:

1.

const envvar = require('envvar')
const objmap = require('object.map')
const sanctuary = require('sanctuary')
const $ = require('some-other-type-checker')

const def = $(sanctuary.env)

const S = objmap(f => f(def), sanctuary.unbound)

2.

const envvar = require('envvar')
const objmap = require('object.map')
const sanctuary = require('sanctuary')
const curry = require('sanctuary-compatible-curry')

const def = (_, __, f, ___) => curry(f)

const S = objmap(f => f(def), sanctuary.unbound)

3.

const envvar = require('envvar')
const objmap = require('object.map')
const $ = require('sanctuary-def')

const Foo = require('./foo')
const Bar = require('./bar')
const fs = require('./fs')

const env = [Foo, Bar]
const def = $(env)

const gs =
  { x: def => def('x', ..., ..., f.x)
  , ...
  }

const U = objmap(f => f(def), fs)
U.env = env
U.unbound = fs

(Apologies for mutating consts throughout)

How possible are these with the old way?

I think (1) would be achieved with using S.create for an unchecked Sanctuary and then recreate the wrapping functions and give them to the type checker [It should be noted that in the 'after' version, the alternative type checker needs to have the same signature as sanctuary-def, so I don't want to make it sound like that it completely uncouples the modules].

(2) is just the same as turning off checking. It just means that another module could implement currying that behaves the same as Sanctuary's

(3) shows using the type checking for another module. This is compared to exposing a create function that acts similarly to Sanctuary's. Looking at (3), though I prefer it to asking others to recreate create, I can't say it seems a huge improvement. It seems it would be better to have a helper function (:: String -> StrMap Constraint -> Array Type -> (String -> StrMap Constraint -> Array Type -> a) -> a aka Cont (String, StrMap Constraint, Array Type)). It's easy to make that helper with this API, though. Not so easy with Create.

There might be some other cases worth looking at, but I thought I would help the ball continue rolling

davidchambers commented 8 years ago

Could you explain the helper function in (3), Rafe?

rjmk commented 8 years ago

Sure. I missed an argument in the type signature. The idea is that def :: String -> StrMap Constraint -> Array Type -> Function -> Function. We could define a function that allows a def to be provided, but holds on to all the other necessary arguments for us. We might call that def and the old def, checkWrap.

const def = (name, constraints, expTypes, impl) => checkWrap =>
  checkWrap(name, constraints, expTypes, impl)

It's not much of a helper, but it's a nice way of reifying a proposed API

davidchambers commented 8 years ago

Thanks for the clarification, Rafe.

I'm going to wait until Sanctuary v0.12.0 is out the door before investigating your proposal further. Feel free to take the lead on this if you feel so inclined. :)