zenika-open-source / immutadot

immutadot is a JavaScript library to deal with nested immutable structures.

Home Page:https://immutadot.zenika.com

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Better currying

nlepage opened this issue · comments

It would be nice to find a better way of managing optional currying.
For now the type of the first arg is tested, if it is a string, curried version is assumed otherwise non curried version.
This is not entirely satisfying, this forbids to use arrays as paths (though it would be nice).
Something based on the number of parameters could be used in some cases, but if there is any optional parameter this is not possible...
Also is currying only the first parameter a good choice ?

I was reading your API yesterday and was perplex of this currying implementation choice :)

I had some question like that before for others libs, and I didn't find a good solution.
The argues will be interesting here! 👍


What I do for now is this:

  • all optionals parameters are in a single argument options
  • all required parameters are currified
const fn = (options = {}) => required1 => required2 => {
  const { optional1, optional2 } = options
  return required1 + required2
}
  • with option: fn({ optional1: 'value', optional2: 'value' })(2)(3)
  • without option: fn()(2)(3) <- 😒

caveats: this is boring to write and empty () when we don't have options
no currified: fn(required1, required2, options)

We can also do this:

const fn = (required1, options = {}) => (required2, options2 = {}) => {
  const { optional1, optional 2}  = { ...options, ...option2 }
  return required1 + required2
}
  • with options all in first function: fn(2, { optional1: 'value', optional2: 'value' })(3)
  • with options splitted: fn(2, { optional1: 'value' })(3, { optional2: 'value' })
  • without option: fn(2)(3)

caveats: the API is hard to understand, WHY there is muliple options ?
not currified: fn(required1, required2, options)

This is not exactly what you are looking for, but maybe the idea of grouping optionals parameter can help you find a better idea.

Hey Fabien,

Thanks for your hints, this is the first open discussion we have on currying in this project.

Optionally currying the first parameter was only motivated by flow():

flow(
  set('nested.a', 'foo'),
  set('nested.b', 'bar'),
)(obj)

I think this is important to keep this, it's a simple and expressive way of grouping different operations on one object.

Adding more currying is not necessary, immutadot is not branded a functional library, but it's always nice.

Our main problem is how do I know if I should curry, without testing the first parameter's type ?

One way could be to force the user to make a first call with a number of parameters lower than the minimum arity of the function in order to "trigger" currying.
As soon the minimum arity minus one is reached, use the maximum arity to know if obj has already been sent, otherwise return a last function to receive obj.

fill() has a minimum arity of 3 and a maximum arity of 5, this would allow to do this:

// No currying
fill(obj, 'nested.array', 'foo')
fill(obj, 'nested.array', 'foo', 1)
fill(obj, 'nested.array', 'foo', 1, 3)
// Currying
fill('nested.array', 'foo')(obj)
fill('nested.array')('foo')(obj)
fill('nested.array')('foo', 1)(obj)
fill('nested.array')('foo', 1, 3)(obj)
fill('nested.array')('foo', 1, 3, obj)
// Invalid currying
fill('nested.array')('foo', obj) // obj would be mistaken for the first optional parameter
fill('nested.array')('foo', 1, obj) // obj would be mistaken for the second optional parameter

The last function could even receive optional parameters, but this is a little weird and doesn't fit with flow():

fill('nested.array', 'foo')(1, obj)
fill('nested.array', 'foo')(1, 3, obj)
fill('nested.array')('foo')(1, obj)
fill('nested.array')('foo')(1, 3, obj)
fill('nested.array')('foo', 1)(3, obj)

Why don't you want to make immutadot a full currified lib ?

  • It would open possibilities described to my previous comment.
  • And flow would still work

(required, options) => (required2, options2)


The solution you speak about close a door : it would not be possible to have an object as a parameter (even an array ?)

  • fill('nested.array', 'foo', { force: false }, obj)
  • fill('nested.array', 'foo', { force: false })(obj)
  • fill('nested.array', 'foo')({ force: false }, obj)

The solution I'm suggesting is only based on the number of parameters received, the type of the parameters doesn't matter, it may be objects, arrays, undefined, whatever...

The examples I give don't use any objects apart from obj, but it's just a coincidence.

In fact I'm not a big fan of options objects 😅

Maybe you should ask a scala/haskell dev how it would handle optional parameters ?

ping @EmrysMyrddin

I made a PoC of what I'm suggesting in #298.

Still with fill()'s example, minimum arity of 3, eventually the rules are rather "simple":

  • If you don't want to use currying, just make a call with at least 3 parameters
  • If you want to use currying:
    • Make a first call with less than 3 parameters
    • Either put the optional parameters before obj or after the last mandatory parameter

@hgwood may also have an opinion on this

I don't like the last solution consisting in having optional parameter in the last curryfied function call.

The problem with this is that you can't use your function with native array API such as map or foreach that doesn't doesn't call the callback only with the current value but also with the index and the entire array.

array.map(fill('nested.attribute', 'hello world'))

This example will have some unexpected behaviour.

@EmrysMyrddin Yes, agreed on this, we will force the last call to be unary with only obj, this was already the case in v1.

Just for the record your example can be done like this:

fill(array, '[:].nested.attribute', 'hello world')

After some more discussions with @frinyvonnick, we think not allowing to put obj with other parameters would be more consistent, ie forbid this:

fill('nested.array')('foo', 1, 3, obj)

We also discussed whether forcing to make a first unary call with only path or not was a good idea, @frinyvonnick wanted to have a more simple rule on how to make a curried call:

Make a first call with a number of parameters lower than minimum arity

vs

Make a first call with only path

We had reached an agreement on not doing it, but doing it in the examples of the documentation.

After some more thinking on my side I think it's not a good idea, and we should not make all our examples like this.
This would be a major breaking change in the usage of flow() (going from set('foo.bar', 'aze') to set('foo.bar')('aze')).
In a large amount of cases, optional parameters (if any) are not used, so the rule make a first call with a number of parameters lower than minimum arity is implicit, you just have to omit obj.

I have update the PoC #298.

It is not necessary to give the maximum arity anymore.

The minimum arity may be omitted, it defaults to fn.length.

closed by #298