tc39 / proposal-partial-application

Proposal to add partial application to ECMAScript
https://tc39.es/proposal-partial-application/
BSD 3-Clause "New" or "Revised" License
1.02k stars 25 forks source link

Partial Application Syntax for ECMAScript

This proposal introduces syntax for a new calling convention (using ~()) to allow you to partially apply an argument list to a call or new expression through the combination of applied arguments (actual values) and placeholder arguments (unbound arguments that become parameters in the resulting partially applied function).

Status

Stage: 1
Champion: Ron Buckton (@rbuckton)

For more information see the TC39 proposal process.

Authors

Proposal

Partial function application allows you to fix a number of arguments to a function call, returning a new function. Partial application is supported after a fashion in ECMAScript today through the use of either Function.prototype.bind or arrow functions:

function add(x, y) { return x + y; }

// Function.prototype.bind
const addOne = add.bind(null, 1);
addOne(2); // 3

// arrow functions
const addTen = x => add(x, 10);
addTen(2); // 12

However, there are several of limitations with these approaches:

To resolve these concerns, we propose the introduction of the following new language features:

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

// apply from the left:
const addOne = add~(1, ?);
addOne(2); // 3

// apply from the right:
const addTen = add~(?, 10);
addTen(2); // 12

// accept a fixed argument list:
const numbers = ["1", "2", "3"].map(parseInt~(?, 10)); // [1, 2, 3]

// specify ordinal placeholder arguments:
const indices = [1, 2, 3].map(identity~(?1)); // [0, 1, 2]

// bind `console` as receiver and accepts exactly one argument:
[1, 2, 3].forEach(console.log~(?));
// prints:
// 1
// 2
// 3

// emulate n-ary arguments like Function.prototype.bind:
const logger = console.log~("[service]", ...);
logger("foo", "bar"); // prints: [service] foo bar

Syntax

The ~() Partial Application Calling Convention

A partially applied call uses a separate calling convention than a normal call. Instead of using () to call or construct a value, you initiate a partial call using ~(). A partially applied call without a placeholder argument essentially fixes any provided arguments into a new function. If the expression being invoked produces a Reference, the this binding of the Reference is preserved. Excess arguments supplied to the resulting function are ignored by default (for more information, see Fixed Arity and Variable Arity later on in this document).

const sayNothing = console.log~();
const sayHi = console.log~("Hello!");

sayNothing();       // prints:
sayNothing("Shhh"); // prints:

sayHi();            // prints: Hello!

const bob = {
  name: "Bob",
  introduce() {
    console.log(`Hello, my name is ${this.name}.`);
  }
};

const introduceBob = bob.introduce~();
introduceBob();     // prints: Hello, my name is Bob.

This would not be the first new calling convention in ECMAScript, which also has tagged templates (i.e., tag`text${expr}`) and nullish function evaluation (i.e., f?.()).

The ? Placeholder Argument

The ? Placeholder Argument can be supplied one or more times at the top level of the argument list of a call or new expression (e.g. f~(?) or o.f~(?)). ? is not an expression, rather it is a syntactic element that indicates special behavior (much like how `...` AssignmentExpression indicates spread, yet is itself not an expression).

// valid
f~(x, ?)          // partial application from left
f~(?, x)          // partial application from right
f~(?, x, ?)       // partial application for any arg
o.f~(x, ?)        // partial application from left
o.f~(?, x)        // partial application from right
o.f~(?, x, ?)     // partial application for any arg
super.f~(?)       // partial application allowed for call on |SuperProperty|
new C~(?)         // partial application of constructor

// invalid
f~(x + ?)         // `?` not in top-level Arguments of call
x + ?             // `?` not in top-level Arguments of call
?.f~()            // `?` not in top-level Arguments of call
super~(?)         // `?` not supported in |SuperCall|
import~(?)        // `?` not supported in |ImportCall|

The ?0 (?1, ?2, etc.) Ordinal Placeholder Argument

The ? token can be followed by a decimal integer value ≥ 0 indicating a fixed ordinal position (i.e., ?0) denoting an Ordinal Placeholder Argument. Ordinal placeholder arguments are especially useful for adapting existing functions to be used as callbacks to other functions expect arguments in a different order:

const printAB = (a, b) => console.log(`${a}, ${b}`);
const acceptBA = (cb) => cb("b", "a");
acceptBA(printAB~(?1, ?0));                // prints: a, b

In addition, ordinal placeholder arguments can be repeated multiple times within a partial application, allowing repeated references to the same argument value:

const add = (x, y) => x + y;
const dup = add(?0, ?0);
console.log(dup(3));                       // prints: 6

Non-ordinal placeholder arguments are implicitly ordered sequentially from left to right. This means that an expression like f~(?, ?) is essentially equivalent to f~(?0, ?1). If a partial application contains a mix of ordinal placeholder arguments and non-ordinal placeholder arguments, ordinal placeholder arguments do not affect the implicit order assigned to non-ordinal placeholder arguments:

const printABC = (a = "arg0", b = "arg1", c = "arg2") => console.log(`${a}, ${b}, ${c}`);
printABC(1, 2, 3);                         // prints: 1, 2, 3
printABC();                                // prints: arg0, arg1, arg2

const printCAA = printABC~(?2, ?, ?0);     // equivalent to: printABC~(?2, ?0, ?0)
printCAA(1, 2, 3);                         // prints: 3, 1, 1
printCAA(1, 2);                            // prints: arg0, 1, 1

const printCxx = printABC~(?2);
printCxx(1, 2, 3);                         // prints: 3, arg1, arg2

By having ordinal placeholder arguments independent of the ordering for non-ordinal placeholder arguments, we avoid refactoring hazards due to the insertion a new ordinal placeholder into an existing partial application.

inserting an ordinal placeholder as the first argument:

-  const g = f~(?, ?, ?);                   // equivalent to: f~(?0, ?1, ?2)
+  const g = f~(?2, ?, ?, ?);               // equivalent to: f~(?2, ?0, ?1, ?2)

inserting an ordinal placeholder between other placeholders:

-  const g = f~(?, ?, ?);                   // equivalent to: f~(?0, ?1, ?2)
+  const g = f~(?, ?, ?0, ?);               // equivalent to: f~(?0, ?1, ?0, ?2)

Fixed Arity

By default, partial application uses a fixed argument list: Normal arguments are evaluated and bound to their respective argument position, and placeholder arguments (?) and ordinal-placeholder arguments (?0, etc.) are bound to specific argument positions in the resulting partially applied function. As a result, excess arguments passed to a partially applied function have no specific position in which they should be inserted. While this behavior differs from f.bind(), a fixed argument list allows us to avoid unintentionally accepting excess arguments:

// (a)
[1, 2, 3].forEach(console.log.bind(console, "element:"));
// prints:
// element: 1 0 1,2,3
// element: 2 1 1,2,3
// element: 3 2 1,2,3

// (b)
[1, 2, 3].forEach(x => console.log("element:", x));
// prints:
// element: 1
// element: 2
// element: 3

// (c)
[1, 2, 3].forEach(console.log~("element:", ?));
// prints:
// element: 1
// element: 2
// element: 3

In the example above, (a) prints extraneous information due to the fact that forEach not only passes the value of each element as an argument, but also the index of the element and the array in which the element is contained.

In the case of (b), the arrow function has a fixed arity. No matter how many excess arguments are passed to the callback, only the x parameter is forwarded onto the call.

The intention of partial application is to emulate a normal call like console.log("element:", 1), where evaluation of the "applied" portions occurs eagerly with only the placeholder arguments being "unapplied". This means that excess arguments have no place to go as part of evaluation. As a result, (c) behaves similar to (b) in that only a single argument is accepted by the partial function application and passed through to console.log.

Variable Arity: Pass Through Remaining Arguments using ...

However, sometimes you may need the variable arity provided by Function.prototype.bind. To support this, partial application includes a ... rest placeholder argument with a specific meaning: Take the rest of the arguments supplied to the partial function and spread them into this position:

const writeLog = (header, ...args) => console.log(header, ...args);
const writeAppLog = writeLog~("[app]", ...);
writeAppLog("Hello", "World!");
// prints:
// [app] Hello World!

const writeAppLogWithBreak = writeAppLog~(..., "\n---");
writeAppLogWithBreak("End of section");
// prints:
// [app] End of section
// ---

A partial application may only have a single ... rest placeholder argument in its argument list, though it may spread in other values using ...expr as you might in a normal call:

const arr = [1, 2, 3];

// The following would be a SyntaxError as the `...` placeholder may only appear once:
// const g = console.log~(?, ..., ...);

// However, a normal spread is perfectly valid. Below, `...arr` will be evaluated immediately
// and spread into the list of applied arguments:
const g = console.log~(?, ...arr, ...);
g("a", "b", "c");                           // prints: a, 1, 2, 3, b, c

Semantics

A call or new expression that uses the ~() calling convention results in a partially applied function. This result is a new function with a parameter for each placeholder argument (i.e., ?, ?0, etc.) in the argument list. If the partial application contains a ... rest placeholder argument, a rest parameter is added as the final parameter of the resulting partially applied function. Any non-placeholder arguments in the argument list becomes fixed in their positions. This is illustrated by the following syntactic conversion:

const g = f~(?, 1, ?);

is roughly identical in its behavior to:

const g = (() => {
  // applied values
  const _callee = f;
  const _applied0 = 1;

  // partially applied function
  return function (_0, _1) { return _callee(_0, _applied0, _1); };
})();

In addition to fixing the callee and any applied arguments, we also fix the the this receiver in the resulting partially applied function. As such, o.f~(?) will maintain o as the this receiver when calling o.f. This can be illustrated by the following syntactic conversion:

const g = o.f~(?, 1);

is roughly identical in its behavior to:

const g = (() => {
  // applied values
  const _receiver_ = o;
  const _callee = _receiver_.f;
  const _applied0 = 1;

  // partially applied function
  return function (_0) { return _callee.call(_receiver_, _0, _applied0); };
})();

The following is a list of additional semantic rules:

Parsing

While this proposal leverages the existing ? token used in conditional expressions, it does not introduce parsing ambiguity as the ? placeholder token can only be used in an argument list and cannot have an expression immediately preceding it (e.g. f~(a? is definitely a conditional while f~(? is definitely a placeholder).

Grammar

MemberExpression[Yield, Await] :
  ...
  `new` MemberExpression[?Yield, ?Await] Arguments[?Yield, ?Await, ~Partial]

CallExpression[Yield, Await] :
  CallExpression[?Yield, ?Await] Arguments[?Yield, ?Await, +Partial]

CoverCallExpressionAndAsyncArrowHead[Yield, Await]:
  MemberExpression[?Yield, ?Await] Arguments[?Yield, ?Await, +Partial]

CallMemberExpression[Yield, Await] :
  MemberExpression[?Yield, ?Await] Arguments[?Yield, ?Await, +Partial]

SuperCall[Yield, Await] :
  `super` Arguments[?Yield, ?Await, ~Partial]

OptionalChain[Yield, Await] :
  `?.` Arguments[?Yield, ?Await, +Partial]
  ...
  OptionalChain[?Yield, ?Await] Arguments[?Yield, ?Await, +Partial]
  ...

Arguments[Yield, Await, Partial] :
  `(` ArgumentList[?Yield, ?Await, ~Partial] `)`
  `(` ArgumentList[?Yield, ?Await, ~Partial], `,` `)`
  [+Partial] [no LineTerminator here] `~` `(` ArgumentList[?Yield, ?Await, +Partial] `)`
  [+Partial] [no LineTerminator here] `~` `(` ArgumentList[?Yield, ?Await, +Partial] `,` `)`

ArgumentList[Yield, Await, Partial] :
  AssignmentExpression[+In, ?Yield, ?Await]
  `...` AssignmentExpression[+In, ?Yield, ?Await]
  ArgumentList[?Yield, ?Await, ?Partial] `,` AssignmentExpression[+In, ?Yield, ?Await]
  ArgumentList[?Yield, ?Await, ?Partial] `,` `...` AssignmentExpression[+In, ?Yield, ?Await]
  [+Partial] `?` DecimalIntegerLiteral?
  [+Partial] `...`
  [+Partial] ArgumentList[?Yield, ?Await, ?Partial] `,` `?` DecimalIntegerLiteral?
  [+Partial] ArgumentList[?Yield, ?Await, ?Partial] `,` `...`

NOTE: It is a SyntaxError for a partial call to have more than one ... placeholder.

Examples

Logging with Timestamps

const log = console.log~({ toString() { return `[${new Date().toISOString()}]` } }, ?);
log("test"); // [2018-07-17T23:25:36.984Z] test

Event Handlers

button.addEventListener("click", this.onClick~(?));

Bound methods

class Collator {
  constructor() {
    this.compare = this.compare~(?, ?);
  }
  compare(a, b) { ... }
}

Passing state through callbacks

// doWork expects a callback of `(err, value) => void`
function doWork(callback) { ... }
function onWorkCompleted(err, value, state) { ... }
doWork(onWorkCompleted~(?, ?, { key: "value" }));

Uncurry this

const slice = Array.prototype.slice.call~(?, ?, ?);
slice({ 0: "a", 1: "b", length: 2 }, 1, 2); // ["b"]

You can also find a number of desugaring examples in EXAMPLES.md.

Relationships to Other Proposals/Language Features

Partial Application and Pipeline

The Pipeline Proposal recently advanced to Stage 2 using the Hack-style for pipelines. While partial application was intended to dovetail with F#-style pipelines, this recent change does not diminish the value of partial application. In fact, the move to Hack-style mitigates the requirement that partial application not have a prefix token, which was a blocking concern from some members of TC39. That said, there is still a place for partial application in conjunction with pipeline:

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

// using Hack-style pipes
elements
  |> map(^, add~(?, 1))
  |> filter(^, greaterThan~(?, 5));

This creates a visual distinction between the topic variable in a Hack-style pipe (^ currently, although that has not been finalized), a partial call (~()), and a placeholder argument (?) that should aid in readability and improve developer intuition about their code will evaluate.

Partial Application and Optional Chaining

Partial Application is supported within an OptionalChain, per the Semantics and Grammar sections, above. As partial application is tied to Arguments, the ~( calling convention would follow ?. in an optional call:

const maybeAddOne = add?.~(?, 1); // undefined | Function
const maybeLog = console?.log~(?); // undefined | Function

Per the semantics of OptionalChain, in both of the examples above the ?. token short-circuits evaluation of the rest of the chain. As a result, if the callee is nullish then the result of both expressions would be undefined. If the callee is not nullish, then the result would be the partial application of the callee.

Open Questions/Concerns

Choosing a different token than ?

There have been suggestions to consider another token aside from ?, given that optional chaining may be using ?. and nullish coalesce may be using ??. It is our opinion that such a token change is unnecessary, as ? may only be used on its own in an argument list and may not be combined with these operators (e.g. f~(??.a ?? c) is not legal). The ? token's visual meaning best aligns with this proposal, and its fairly easy to write similarly complex expressions today using existing tokens (e.g. f(+i+++j-i---j) or f([[][]][[]])). A valid, clean example of both partial application, optional chaining, and nullish coalesce is not actually difficult to read in most cases: f~(?, a?.b ?? c).

Definitions

Resources

TODO

The following is a high-level list of tasks to progress through each stage of the TC39 proposal process:

Stage 1 Entrance Criteria

Stage 2 Entrance Criteria

Stage 3 Entrance Criteria

Stage 4 Entrance Criteria