pipeline operator.png
Jul 30, 2019

TypeScriptでもpipeline operator(もどき)

JavaScriptはそのシンタックスの特性上、関数の演算結果を次の関数の引数として与えてネストしていくにはつらい言語です。(いわゆる畳み込み)

簡単な例を見てみましょう。

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

console.log(exclaim(capitalize(doubleSay("hello"))))
// "Hello, hello!"

後続の処理を前置してくと当然このような難読なコードになります。

このままでは読みづらいので、実世界ではこのように書かれることが多いのではないでしょうか?

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

const saidTwice = doubleSay("hello")
const capitalized = capitalize(saidTwice)
const exclaimed = exclaim(capitalized)

console.log(exclaimed)

変数自体に必要性はないけど可読性のためにブツ切りにせざるをえないというのはなかなかyackです。(適切な粒度で変数化すること自体が悪いわけではない)

そこでECMAScriptの新たな仕様として注目されているのが現在Stage 1 proposalとして上がっているpipeline-operator

Embedded content: https://github.com/tc39/proposal-pipeline-operator

2017年にStage 1に採択されてから常に議論の対象となっており現在もF# PipelinesとSmart Pipelinesのどちらに転ぶか曖昧な状況ではありますが、概ね下記のような記法となります。

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

"hello" |> doubleSay |> capitalize |> exclaim |> console.log

処理の流れがかなり追いやすくなりますね。任意の演算結果がpipeを通して次項の引数に渡っていくのが直感的に把握できるかと思います。

上記のコードはこちらのBabel pluginを利用すれば実際にブラウザやNode.js上で実行することが可能です。

Embedded content: https://babeljs.io/docs/en/babel-plugin-proposal-pipeline-operator

TypeScriptサポート

先述のBabel pluginを通せばjsのコード上ではpipeline operatorをお試しすることができますが、残念ながらTypeScriptのコードに記述することはできません(2019年7月現在)

詳細はこちらのスレッドを参照。

Embedded content: https://github.com/Microsoft/TypeScript/issues/17718

これまでTSでサポートされてきたES Nextの新機能とは異なり大幅にシンタックスが変化するため既存のparserではコンパイルできないわけですが、改修を入れるにもまだ仕様自体がStage 1でありどう転ぶか分からないため様子見といった感じですね。

議論の派生としてLanguage Service APIをよりpluggableにしてcustom transformerを噛ませられるようにしようぜ!という意見も出てきたので、個人的には今後もこの辺の動きをウォッチしつづけたいと思います。

とはいえ...

とはいえJSのコードをより関数型Likeに記述できるこんな便利機能を待つこともできないので、下記のようなpipeline operatorもどきを用意しました。

export function pipe(first: Function, ...args: Function[]): any {
  return args && args.length ? args.reduce((result, next) => next(result), first()) : first()
}

引数に任意の個数の関数を取り、それぞれの演算結果をreduceしていく関数です。

型定義もこんな感じで力技で用意。

export function pipe<T1>(first: (...args: any[]) => T1): T1
export function pipe<T1, T2>(first: (...args: any[]) => T1, second: (a: T1) => T2): T2
export function pipe<T1, T2, T3>(first: (...args: any[]) => T1, second: (a: T1) => T2, third: (a: T2) => T3): T3
export function pipe<T1, T2, T3, T4>(first: (...args: any[]) => T1, second: (a: T1) => T2, third: (a: T2) => T3, fourth: (a: T3) => T4): T4
export function pipe<T1, T2, T3, T4, T5>(first: (...args: any[]) => T1, second: (a: T1) => T2, third: (a: T2) => T3, fourth: (a: T3) => T4, fifth: (a: T4) => T5): T5
export function pipe<T1, T2, T3, T4, T5, T6>(first: (...args: any[]) => T1, second: (a: T1) => T2, third: (a: T2) => T3, fourth: (a: T3) => T4, fifth: (a: T4) => T5, sixth: (a: T5) => T6): T6
export function pipe<T1, T2, T3, T4, T5, T6, T7>(first: (...args: any[]) => T1, second: (a: T1) => T2, third: (a: T2) => T3, fourth: (a: T3) => T4, fifth: (a: T4) => T5, sixth: (a: T5) => T6, seventh: (a: T6) => T7): T7
export function pipe<T1, T2, T3, T4, T5, T6, T7, T8>(first: (...args: any[]) => T1, second: (a: T1) => T2, third: (a: T2) => T3, fourth: (a: T3) => T4, fifth: (a: T4) => T5, sixth: (a: T5) => T6, seventh: (a: T6) => T7, eighth: (a: T7) => T8): T8

チェインさせたい演算ペアが増えたら適宜継ぎ足してあげてください。

このhelperを使えば下記のように処理を記述することができます。

const doubleSay = (str: string) => str + ', ' + str
const capitalize = (str: string) => str[0].toUpperCase() + str.substring(1)
const exclaim = (str: string) => str + '!'
pipe(
  () => "hello",
  doubleSay,
  capitalize,
  exclaim
)
// "Hello, hello!"

可読性も問題なし!!

コードサンプル

四則演算

const add = (b: number) => (a: number) => a + b
const multiple = (b: number) => (a: number) => a * b
const divide = (b: number) => (a: number) => a / b

// (((2 + 3) * 4) / 5) =  4
const result = pipe(
  () => 2,
  add(3),
  multiple(4),
  divide(5)
)

console.log(result) // 4

カリー化した関数との併用

const pluck = <T extends object, K extends keyof T>(data: T[], key: K): T[K][] => data.map(item => item[key])

const curriedPluck = <T extends object, K extends keyof T>(key: K) => (arr: T[]) => pluck(arr, key)

const curriedMap = <T, F extends (arg: T) => any>(fn: F) => (arr: T[]) => arr.map<ReturnType<F>>(fn)

const curriedReduce = <T, P extends T, F extends (previousValue: T, currentValue: T, currentIndex?: number, array?: T[]) => T>(fn: F, initialValue: T) => (arr: P[]) =>
  arr.reduce(fn, initialValue)

const data = [
  {
    name: {
      firstname: 'John',
      lastname: 'Paul'
    }
  },
  {
    name: {
      firstname: 'Lucia',
      lastname: 'Škroupova'
    }
  }
]

const reulst = pipe(
  () => data,
  curriedPluck('name'),
  curriedPluck('firstname'), // ['John', 'Lucia']
  curriedMap(i => i.length), // [4, 5]
  curriedReduce((acc, ac) => acc + ac, 0) // 9
)

※ Array#mapやArray#reduceの型は適当

型もバッチリ効いてます。

Screen Shot 2019-07-29 at 15.16.02

これまで演算結果を変数に落とし込んで小分けにしていた処理を、1つの pipe()の中にブワーッと書けるので個人的にはかなりパラダイムシフトでした。

みなさんも快適な畳み込みライフを。