sanctuary-js / sanctuary

:see_no_evil: Refuge from unsafe JavaScript
https://sanctuary.js.org
MIT License
3.03k stars 94 forks source link

readable code without Ramda-style currying #438

Closed davidchambers closed 6 years ago

davidchambers commented 7 years ago

Sanctuary is defined in part by what it does not support. We have done a good job of managing complexity and entropy, and we must continue to do so if Sanctuary is to live a long, healthy life.

Ramda-style currying—the ability to write both f(x)(y) and f(x, y)—is a source of complexity. I've seen this complexity as necessary to prevent code written with Sanctuary from looking strange to newcomers, which would limit the library's initial appeal and thus limit the library's adoption.

Last night it occurred to me that we could possibly solve (or at least mitigate) the ")(" problem by tweaking the way in which we format function applications.

The ")(" problem

In JavaScript, this reads very naturally:

f(x, y, z)

This, on the other hand, seems unnatural:

f(x)(y)(z)

A day ago my impression was that the only aesthetic problem was having opening parens follow closing parens. I now see a second aesthetic problem, as I hope this example demonstrates:

f(g(x)(y))(h(z))

There's no space. There's a significant difference visually between x)(y and x, y. The nesting of subexpressions above is not immediately clear to a human reader. When we include space between arguments—as is common practice in JavaScript—the nesting is clear:

f(g(x, y), h(z))

This clarity is the primary benefit of Ramda-style currying. I consider S.concat(x)(y) bad style not because of the )( but because if used consistently this style results in expressions which are less clear than their more spacious equivalents.

It's worth noting that multiline function applications are also natural with the comma style:

f(x,
  y,
  z)

x, y, and z are obviously placeholders for longer expressions in this case.

Here's the )(-style equivalent:

f(x)
 (y)
 (z)

My concern is that visually the x is more tightly bound to f than it is to y and z, making the first argument feel privileged in some way.

Learning from Haskell

Sanctuary brings many good ideas from Haskell to JavaScript. Perhaps most important is the combination of curried functions and partial application. We might be able to learn from Haskell's approach to function application.

In Haskell, function application is considered so important that a space is all it requires syntactically: f x in Haskell is equivalent to f(x) in JavaScript. The associativity of function application is such that f x y is equivalent to (f x) y, which is to say that what we write as f(x)(y) in JavaScript could simply be written f x y in Haskell.

Let's consider how the previous examples would look in Haskell:

f x y z
f (g x y) (h z)
f x
  y
  z

All three Haskell expressions are less noisy than both of their JavaScript equivalents. Note that in the second expression it's necessary to use parens. We'll return to this idea shortly.

A small change can make a big difference

The proposal:

When applying a function, include a space before the opening paren.

This means we'd write f (x) rather than f(x), and f (x) (y) rather than f(x)(y). This gives expressions breathing room they lack when formatted in the )( style.

Let's revisit the examples from earlier to see the formatting tweak in action.

f (x) (y) (z)

This looks odd to me now, but I think it could become natural. The key is to see the spaces as the indicators of function application (as in Haskell) and the parens merely as grouping syntax for the subexpressions. It's interesting to note that the code above is valid Haskell.

f (g (x) (y)) (h (z))

Again, this is valid Haskell with "unnecessary" grouping around x, y, and z. The spaces make it easier for me to determine that f is being applied to two arguments (one at a time). This would be even clearer if the arguments were written on separate lines:

f (g (x) (y))
  (h (z))

One could even go a step further:

f (g (x)
     (y))
  (h (z))

This leads quite naturally to the original multiline example:

f (x)
  (y)
  (z)

The space is advantageous in this case too, separating x from f so x binds more tightly, visually, with the other arguments than with the function identifier.

Realistic example

Here's a function from sanctuary-site, as currently written:

//    version :: String -> Either String String
const version =
def('version',
    {},
    [$.String, Either($.String, $.String)],
    pipe([flip_(path.join, 'package.json'),
          readFile,
          chain(encaseEither(prop('message'), JSON.parse)),
          map(get(is(String), 'version')),
          chain(maybeToEither('Invalid "version"'))]));

Here's the function rewritten using the proposed convention:

//    version :: String -> Either String String
const version =
def ('version')
    ({})
    ([$.String, Either ($.String) ($.String)])
    (pipe ([flip_ (path.join) ('package.json'),
            readFile,
            chain (encaseEither (prop ('message')) (JSON.parse)),
            map (get (is (String)) ('version')),
            chain (maybeToEither ('Invalid "version"'))]));

Here's a Lispy alternative which makes the nesting clearer:

//    version :: String -> Either String String
const version =
def ('version')
    ({})
    ([$.String, Either ($.String) ($.String)])
    (pipe ([flip_ (path.join)
                  ('package.json'),
            readFile,
            chain (encaseEither (prop ('message'))
                                (JSON.parse)),
            map (get (is (String))
                     ('version')),
            chain (maybeToEither ('Invalid "version"'))]));

I like the comma style best, although I can imagine growing to like the proposed convention. Even if we decide that the proposed convention makes code slightly less easy to read we should consider adopting it in order to reap the benefits outlined below.

Benefits of replacing Ramda-style currying with regular currying

Although this proposal is focused on an optional formatting convention, it is motivated by the desire to simplify. If we decide that the proposed convention addresses the readability problems associated with )( style, we can replace Ramda-style currying with regular currying. This would have several benefits:

Poll

I'd love to know where you stand on this.

Reaction Meaning
:heart: I already use f(x)(y) or f (x) (y) exclusively.
:thumbsup: I currently use f(x, y) but this proposal has encouraged me to adopt f(x)(y) or f (x) (y).
:confused: I prefer f(x, y) but find the arguments for dropping Ramda-style currying compelling. I would adopt f(x)(y) or f (x) (y) if necessary.
:thumbsdown: I prefer f(x, y) and want Sanctuary to continue to use Ramda-style currying.

Feel free to vote based on your first impressions but to change your vote if you change your mind.

KiaraGrouwstra commented 6 years ago

I hadn't seen maybe callbacks, but one approach is to put all arguments in an object with some keys optional.

On Dec 18, 2017 10:47 AM, "David Komer" notifications@github.com wrote:

Re: commas, I find it also removes some friction in thinking where to place them for multi-line calls. One less thing to think about :)

One issue I've come up against though is for optional parameters. Is the idiomatic way to deal with it to always wrap in a Maybe? e.g.

const foo = arg => maybeCallback => { //do stuff with arg, get result S.map (c => c(result)) (maybeCallback); }

foo (bar) (S.Nothing); foo (baz) (S.Just(myCallback));

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/sanctuary-js/sanctuary/issues/438#issuecomment-352375928, or mute the thread https://github.com/notifications/unsubscribe-auth/AC6uxZAPtsNNBh-xBoz5xP86-F-r8-Heks5tBjSjgaJpZM4PKLTy .

davidchambers commented 6 years ago

Tasks:

onetom commented 5 years ago

Is there a https://prettier.io configuration for this function application spacing style? I think it goes against adoption if we help people to configure their automatic code formatters accordingly. I'm just using the built-in IntelliJ settings, btw, but I would consider switching to prettier.

onetom commented 5 years ago

I just noticed https://github.com/joelnet/eslint-config-mojiscript which seems to achieve this style with eslint.

davidchambers commented 5 years ago

If you find a solution that works for you, @onetom, please share it here. We could then add it to the readme and website.

RichardForrester commented 5 years ago

I can confirm that eslint-config-mojiscript allows for automatic reformatting of the parentheses and some other helpful linting.

npm i -S eslint-config-mojiscript

.eslintrc.js

{ 
  ...,
  extends: 'mojiscript',
  ...
}

UPDATE:

The eslint rule that allows for this to be automatically formatted is func-call-spacing. Should be as easy as adding:

.eslintrc.js

{
  ...,
  rules: {
    'func-call-spacing': ['error', 'always', { allowNewlines: true }]
  },
  ...
}

Typescript users can make use of the same auto-fix by ditching tslint in favor of eslint as TS has already announced they intend to only support eslint in the future. The setup is here. Then, same rule except '@typescript-eslint/func-call-spacing' instead of 'func-call-spacing'.

Also, WebStorm supports an auto-fix through the built-in options Preferences > Editor > Code Style > [Typescript/Javascript] > Spacing > Before Parentheses > Function Call Parentheses.

davidchambers commented 5 years ago

Thank you for sharing this information, @RichardForrester.

alexandermckay commented 4 years ago

@RichardForrester did you figure out how to make this rule work with prettier?

RichardForrester commented 4 years ago

@alexandermckay It’s been a while since I messed with this, but from memory you don’t really need the mojiscript to use the function-call-spacing rule, and as far as working with prettier, I usually bind a command to first run prettier and then run eslint auto-fix.

alexandermckay commented 4 years ago

@RichardForrester thank you for the tips! It was actually quite simple to get them to work together. I have created a repo which shows how to set this up for anyone else using a combination of prettier, eslint and VSCode.