Skip to content

Latest commit

 

History

History
346 lines (256 loc) · 9.75 KB

README.md

File metadata and controls

346 lines (256 loc) · 9.75 KB

ESNext Proposal: F# Pipeline Operator

Introduction

This proposal introduces a new operator |> similar to F#, OCaml, Elixir, Elm, Julia, Hack, and LiveScript, as well as UNIX pipes. It's a backwards-compatible way of streamlining chained function calls in a functional manner, and provides a practical alternative to extending object prototypes for methods.

The Problem

At its worst, a series of data transformations can become a deeply-nested and hard-to-read mess of non-linear data flow. Take a look at the following example:

console.log(
  await stream.write(
    new User.Message(
      capitalize(
        doubleSay(
          await promise,
          ', '
        )
      ) + '!'
    )
  )
);

Notice how your eyes have to move back and forth, up and down, in order to follow the flow through the code. The inclusion of await increases the complexity, as now you have to consider both the data flow as well the impact on the event loop.

This may appear to be a contrived example, but application code often does this kind of data manipulation. In the real world, you might break this out onto separate lines, leading to overly verbose code and unnecessary intermediate variables.

The Solution

The pipeline operator simplifies the process of chaining several functions together. We can flatten the above invocation to this:

promise
  |> await
  |> x => doubleSay(x, ', ')
  |> capitalize
  |> x => x + '!'
  |> x => new User.Message(x)
  |> x => stream.write(x)
  |> await
  |> console.log;

Now the flow reads top to bottom, left to right, without requiring your eyes to jump around the code to follow it. The points at which async control flow comes into play is clear. Steps can be easily added or removed from the flow, or collapsed into each other, without requiring the addition or removal of lots of parentheses or indentation, enabling git diffs to be clearer as well.

Overview

The pipeline operator provides syntactic sugar over a function call with a single argument. In other words, 64 |> sqrt would desugar to sqrt(64). This application can be chained, producing a clear sequence of operations to perform on an input.

Basic Example

Given the following functions:

const doubleSay = str => str + ", " + str;
const capitalize = str => str[0].toUpperCase() + str.substring(1);
const exclaim = str => str + '!';

...the following invocations are equivalent:

const result = exclaim(capitalize(doubleSay("hello")));
result //=> "Hello, hello!"

const result = "hello"
  |> doubleSay
  |> capitalize
  |> exclaim;

result //=> "Hello, hello!"

Use with N-ary Functions

The pipeline operator does not need any special rules for functions with multiple arguments; JavaScript can already handle such cases. This keeps the syntactic overhead of the new operator to a minimum while enabling other future syntax to complement it.

For example, given the following functions:

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

const boundScore = (min, max, score) =>
  Math.max(min, Math.min(max, score));

...you can use an arrow function to handle multi-argument functions:

const person = { score: 25 };

const newScore = person.score
  |> double
  |> n => add(7, n)
  |> n => boundScore(0, 100, n);

newScore //=> 57

// As opposed to:
let newScore = boundScore(0, 100, add(7, double(person.score)));

As you can see, because the pipeline operator always pipes a single result value, it plays very nicely with the single-argument arrow function syntax. Because the pipeline operator's semantics are pure and simple, JavaScript engines can optimize away the arrow function, as the Babel plugin currently does.

Impact on Precedence

Arrow functions do not require parentheses, allowing developers to use the pipeline operator to build up a composition chain. This means that this:

const c = x => x |> a |> b

...will parse as:

const c = x => (x |> a |> b)

...with the body of the arrow function a single pipeline. Once inside a pipeline, arrow functions are terminated at the first operator, meaning this:

const updateScore = score =>
  score
    |> double
    |> n => add(7, n)
    |> n => boundScore(0, 100, n);

...will parse as:

const updateScore = score => (
  score
    |> double
    |> (n => add(7, n))
    |> (n => boundScore(0, 100, n));
)

...enabling an ergonomic means for function composition

TODO: Add this behavior to the spec.

Use with Methods

When a pipeline is applied to method, the receiver of the method is bound to its current value. That is:

x |> a.f;

...will desugar as:

a.f(x);

...ensuring the method f is called with the correct this (a).

Use with await

The pipeline operator treats await similar to a unary function. await can appear in the pipeline like so:

const result = promise |> await;

which desugars to:

const result = await promise;

This enables awaiting the previous value in the pipeline. That means the following:

const user = url
  |> api.get
  |> await
  |> r => r.json()
  |> await
  |> j => j.data.user;

desugars roughly as follows:

const _temp1 = api.get(url);
const _temp2 = await _temp1;
const _temp3 = _temp2.json();
const _temp4 = await _temp3;
const user = _temp4.data.json;

Attempting to pipe to x |> await f is a Syntax Error. Parentheses would be required (x |> (await f)) or it needs to be piped through f (x |> f |> await), depending on your intention.

Motivating Examples

Object Decorators

Mixins via Object.assign are great, but sometimes you need something more advanced. A decorator function is a function that receives an existing object, adds to it (mutative or not), and then returns the result.

Decorator functions are useful when you want to share behavior across multiple kinds of objects. For example, given the following decorators:

const greets = person => {
  person.greet = () => `${person.name} says hi!`;
  return person;
};
const ages = age => person => {
  person.age = age;
  person.birthday = () => { person.age += 1; };
  return person;
};
const programs = favLang => person => {
  person.favLang = favLang;
  person.program = () => `${person.name} starts to write ${person.favLang}!`;
  return person;
};

...you can create multiple "classes" that share one or more behaviors:

function Person (name, age) {
  return { name } |> greets |> ages(age);
}
function Programmer (name, age) {
  return { name }
    |> greets
    |> ages(age)
    |> programs('javascript');
}

Validation

Validation is a great use case for pipelining functions. For example, given the following validators:

const bounded = (prop, min, max) => obj => {
  if (obj[prop] < min || obj[prop] > max) throw Error('out of bounds');
  return obj;
};
const format = (prop, regex) => obj => {
  if (!regex.test(obj[prop])) throw Error('invalid format');
  return obj;
};

...we can use the pipeline operator to validate objects quite pleasantly:

const createPerson = attrs =>
  attrs
    |> bounded('age', 1, 100)
    |> format('name', /^[a-z]$/i)
    |> Person.insertIntoDatabase;

Usage with Prototypes

Although the pipeline operator operates well with functions that don't use this, it can still integrate nicely into current workflows:

import Lazy from 'lazy.js'

getAllPlayers()
  .filter(p => p.score > 100)
  .sort()
  |> _ => Lazy(_)
    .map(p => p.name)
    .take(5)
  |> _ => renderLeaderboard('#my-div', _);

Arrow functions are able to easily handle this use case.

Importable Methods

In the above example, lazy.js could update its API to use Importable Methods instead of attaching them to the prototype. This allows the importing application to only include the methods they actually use and tree-shake the code the don't use.

Developers can use it in essentially the same way they do now with a fluent interface, except they use |> instead of .:

import Lazy, { map, take } from 'lazy.js';
import { filter, sort } from './array-utils';

getAllPlayers()
  |> filter(p => p.score > 100)
  |> sort()
  |> Lazy
  |> map(p => p.name)
  |> take(5)
  |> _ => renderLeaderboard('#my-div', _);

Now, lazy.js can have all its unused methods pruned from the final application bundle.

Mixins

"Real" Mixins have some syntax problems, but the pipeline operator cleans them up quite nicely. For example, given the following classes and mixins:

class Model {
  // ...
}
let Editable = superclass => class extends superclass {
  // ...
};
let Sharable = superclass => class extends superclass {
  // ...
};

... we can use the pipeline operator to create a new class that extends Model and mixes Editable and Sharable, with a more readable syntax:

// Before:
class Comment extends Sharable(Editable(Model)) {
  // ...
}
// After:
class Comment extends Model |> Editable |> Sharable {
  // ...
}

Real-world Use Cases

Check out the Example Use Cases wiki page to see more possibilities.

Implementation

Check out @babel/plugin-proposal-pipeline-operator.