sanctuary-js / sanctuary-def

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

def's signature #115

Closed dmitriz closed 7 years ago

dmitriz commented 7 years ago

I wonder if there is any reason for choosing this signature: https://github.com/sanctuary-js/sanctuary-def/blob/master/index.js#L62

//    add :: Number -> Number -> Number

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

The {} looks counter-intuitive (unless one goes deep into the code, of course, and reads the definitions of the variables, which, however, are not visible from the function invocation in this text aimed at the newcomers).

Also putting both function arguments and return values inside a single array feels somewhat confusing as their meaning is fundamentally different. (Even mathematically, you would have to switch to duals when moving variables between source and target of a function.)

But even more importantly, the correct signature of this function seems to be

//   add :: (Number, Number) -> Number,

which is not the same as the curried one above, and works differently in JavaScript. Even worse things can happen like placing the curried version

const add = x => y => x + y

into Ramda's reduce, which leads to different (and less intuitive) results than its expected uncurried sister

const add = (x, y) => x + y

So the question arises, which of these two signatures is reflected in this declaration:

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

Looking at the def definition https://github.com/sanctuary-js/sanctuary-def/blob/master/index.js#L2346, I wonder if there any reason not to declare it as

function def({name, constraints, type, impl}) { ...

Then

//   add :: (Number, Number) -> Number,

would become

def({
    name: 'add', 
    type: $.Function([$.Number, $.Number], $.Number),
    impl: (x, y) => x + y
})

whereas

//   add :: Number -> Number -> Number,

would become

def({
    name: 'add', 
    type: $.Function($.Number, $.Function($.Number, $.Number)), 
    impl: (x, y) => x + y
})

and the more complex signature

//   add :: (Number -> Number) -> Number,

would become

def({
    name: 'eval', 
    type: $.Function($.Function($.Number, $.Number)),
    target: $.Number, 
    impl: (f, x) => f(x)
})

or, the real world example

//   Future :: ( a -> (), (b -> ()) -> Cancel ) -> Future a b

would become

def({
    name: 'Future', 
    type: $.Function($.Function(A, $.Empty), $.Function($.Function(B, $.Empty), Cancel), Future A B)),
    impl: (x, y) => x + y
})
Avaq commented 7 years ago

The {} looks counter-intuitive

Once you learn that this object holds the type class constraints I think it becomes rather intuitive. Or at least explicit. The empty object shows us that there are no constraints on the type variables.

putting both function arguments and return values inside a single array feels somewhat confusing

I think it's meant to closely resemble Hindley-Milner syntax, where the return value of the function is simply the last item in a list separated by ->.

the correct signature of this function seems to be add :: (Number, Number) -> Number

Actually, def also curries the "implementation". So Number -> Number -> Number is correct. The fact that it's also possible to call it by passing multiple arguments at once is kind of like a "syntactic sugar" to make it less painful to apply multiple arguments with JavaScript.

Even worse things can happen like placing the curried version into Ramda's reduce

I'm not sure which "curried version" you are referring to. You shouldn't pass manually curried functions such as your curried add into def, as def will fail to call them properly (assuming that's what you mean?).

If defined via def correctly, a function can be safely treated as either curried or uncurried. It should therefore be safe to pass it into functions like Ramda's reduce (which expects (a, b) -> a) or Sanctuary's reduce (which expects a -> b -> a).

I wonder if there any reason not to declare it as function def({name, constraints, type, impl}) { ...

Sanctuary's API consistently chooses positional function arguments over named arguments (with the exception of create, after some debate). It's a preference of @davidchambers, I believe. It also makes it easier to curry these functions, since "named arguments" is really just a single argument of an object with mixed value types.

davidchambers commented 7 years ago

Thanks for the detailed response, @Avaq.

Why positional arguments in this case? Let's consider some Haskell code:

concat :: Semigroup a => a -> a -> a
concat x y = ...

The sanctuary-def equivalent:

const concat = def('concat', {a: [Semigroup]}, [a, a, a], (x, y) => ...);

The function name, type-class constraints, argument types, return type, and implementation appear in the same order in both cases (except that Semigroup a becomes a: [Semigroup]). The drawback of positional arguments—the need to choose an argument order—is not applicable as there's a clear order (for those familiar with Hindley–Milner at least).

Is there an advantage to positional arguments over a record argument in this case? Yes. Brevity. The concat example above would be significantly noisier we were forced to label the constituents. Even the current API (which is as minimal as is possible in JavaScript) is more verbose than we would like (#39).

dmitriz commented 7 years ago

@Avaq Many thanks for your clarification. I'd like to focus first on the signature issue (or the issue of my wrong understanding 😄 first):

the correct signature of this function seems to be add :: (Number, Number) -> Number

Actually, def also curries the "implementation". So Number -> Number -> Number is correct. The fact that it's also possible to call it by passing multiple arguments at once is kind of like a "syntactic sugar" to make it less painful to apply multiple arguments with JavaScript.

Hm... my function is really auto-curried by def? I didn't expect it 😄 . Then how can I pass a function like fetch(url, options) and use it as fetch(url) to enjoy the default options? I suppose I would not want it curried in that case.

Even worse things can happen like placing the curried version into Ramda's reduce

I'm not sure which "curried version" you are referring to. You shouldn't pass manually curried functions such as your curried add into def, as def will fail to call them properly (assuming that's what you mean?).

Hm... now I am puzzled. Passing the (manually) curried version of add would lead to different results? I didn't expect that either. Really confused now. 😕

dmitriz commented 7 years ago

@davidchambers Thank you for elaborating, I see, it would probably be helpful to mention in the README at least few facts about the signature like that it aims to imitate Hindley-Milner.

I am surely with both hands for brevity, but how would you support functions like fetch taking optional arguments? That would mean, you can't auto-curry them of course, so is it done via some setting in the constraint argument?

davidchambers commented 7 years ago

Hm... my function is really auto-curried by def?

That's right. def is like R.curry with optional type checking.

Then how can I pass a function like fetch(url, options) and use it as fetch(url) to enjoy the default options? I suppose I would not want it curried in that case.

Actually, this is exactly the reason to curry functions in the first place: to allow more specific functions to be defined by partial application of a more general one. This is what I suggest:

//    defaultOpts :: FetchOptions
const defaultOpts = {...};

//    fetch :: FetchOptions -> String -> Future String String
const fetch =
def('fetch',
    {},
    [FetchOptions, $.String, $Future($.String, $.String)],
    (opts, url) => {...});

//    get :: String -> Future String String
const get = fetch({method: 'GET'});

Hm... now I am puzzled.

What Aldwin was saying is that the implementation provided to def should be something like (opts, url) => {...} rather than opts => url => {...}.

safareli commented 7 years ago

<3 this:

const get = fetch({method: 'GET'});

@dmitriz take a look at:

they demonstrate how data-last approach with currying is great.

dmitriz commented 7 years ago

@davidchambers I see, yes, that sounds like a better and safer way. So the def provides the complete Ramda's curry functionality? Which feels like "over-currying" ;). Would be nice to mention in the Readme somewhat explicitly.

What Aldwin was saying is that the implementation provided to def should be something like (opts, url) => {...} rather than opts => url => {...}.

Hm... if passing the curried function opts -> url -> ... directly leads to a different outcome, I wonder what is the intended behaviour in that case and the reason for that?

davidchambers commented 7 years ago

Would be nice to mention in the Readme somewhat explicitly.

Is this not explicit?

sanctuary-def is a run-time type system for JavaScript. It facilitates the definition of curried JavaScript functions which are explicit about the number of arguments to which they may be applied and the types of those arguments.

Applying add to two arguments gives the expected result:

add(2, 2);
// => 4

Applying add to fewer than two arguments results in a function awaiting the remaining arguments. This is known as partial application. Partial application is convenient as it allows more specific functions to be defined in terms of more general ones:

//    inc :: Number -> Number
const inc = add(1);

inc(7);
// => 8

Hm... if passing the curried function opts -> url -> ... directly leads to a different outcome, I wonder what is the intended behaviour in that case and the reason for that?

When currying a binary function via def, the function provided must be of type (a, b) -> c rather than a -> b -> c. This was chosen as it's much more convenient when one does not have access to arrow functions. I'm strongly opposed to ad hoc polymorphism so inspecting the function provided in an effort to support both forms is off the table. You could define and use an uncurry function if you find yourself with lots of manually curried functions you'd like to def.

dmitriz commented 7 years ago

Would be nice to mention in the Readme somewhat explicitly.

Is this not explicit?

I can only see it at the very beginning:

sanctuary-def is a run-time type system for JavaScript. It facilitates the definition of curried JavaScript functions which are explicit about the number of arguments to which they may be applied and the types of those arguments.

Which can be easily misread as it takes curried functions and makes them safe by enforcing the number of arguments. That is how I read it, which is apparently wrong.

Something along the lines, that it actually requires a plain uncurried function, but produces its fully curried "sister" with full Ramda's functionality (if it is?) would have avoided the misunderstanding for me.

Not meant as criticism (the readme is awesome otherwise), just a suggestion to improve it.


When currying a binary function via def, the function provided must be of type (a, b) -> c rather than a -> b -> c. This was chosen as it's much more convenient when one does not have access to arrow functions. I'm strongly opposed to ad hoc polymorphism so inspecting the function provided in an effort to support both forms is off the table. You could define and use an uncurry function if you find yourself with lots of manually curried functions you'd like to def.

Yes, without arrow function that is more tedious indeed. I also now can see the reason behind your decision. I might not be the last one to come back asking about it though, so some sort of FAQ might smoothen it for you to have to explain it again. 😄

davidchambers commented 7 years ago

Something along the lines, that it actually requires a plain uncurried function, but produces its fully curried "sister" with full Ramda's functionality (if it is?) would have avoided the misunderstanding for me.

I just opened #116. Hopefully it clarifies the behaviour of def.

dmitriz commented 7 years ago

Why Ramda? Favoring Curry Hey Underscore, You're Doing It Wrong! they demonstrate how data-last approach with currying is great.

@safareli Thanks, great links! Ironically Ramda's reduce actually requires the uncurried function and does not work with its curried version, as one might expect. ;)