JavaScript
TypeScript
lodash
ramda

TypeScript(JavaScript)で関数型言語の機能をつまみ食いする

関数型言語のことなんて知らなくても、ただそこに便利な関数があるので使っていきましょうというお話です。
LodashRamdaには、リスト操作やオブジェクト操作に関する便利な関数がたくさんあります。

["a", "b"][1, 2] から { a: 1, b: 2 } を作る処理を思い浮かべてください。
何も知らなければforループを使って書いたりしそうですが、_.zipObjectR.zipObjを知っていればこれを使うだけでできてしまいます。

以下で、Clojureのclojure.coreにあるよく使う関数を中心に、Lodash, Ramdaを使ったときにどうやって実現できるのか調べてみました。

調べてみた感想としては以下のとおりです。

  • JavaScript標準の関数ではまだまだ不足している部分が多く、LodashとRamdaは一長一短
  • Lodashは副作用がある関数(対象オブジェクトを変更するもの)と副作用がない関数がある。Ramdaは副作用がないものばかり
    • Lodashで副作用がないものを使いたいならlodash/fpを使えってことなんでしょうね
  • RamdaはTypeScriptの型定義と相性が悪い部分がありそうで、anyを使う場面が多い

TypeScriptの場合、以下でライブラリをインストールするとサンプルコード実行ができます。
@types/ramdaの型定義は少し古いっぽいです。公式リンクにあったtypes/npm-ramda#distを使っています)

npm i -SE lodash ramda
npm i -DE @types/lodash types/npm-ramda#dist

基本的なもの。リスト要素の追加・取得など

tsconfig.jsonのtargetはES2017にしています。

main.ts
import * as _ from "lodash";
import * as R from "ramda";

console.log("range:", // => [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
    _.range(0, 10),
    R.range(0, 10));

const dataList = ["A", "B", "C", "D", "E"];
console.log("append:", // => [ 'A', 'B', 'C', 'D', 'E', 'Z' ]
    [...dataList, "Z"],
    _.concat(dataList, "Z"),
    R.append("Z", dataList));

console.log("prepend:", // => [ 'Z', 'A', 'B', 'C', 'D', 'E' ]
    ["Z", ...dataList],
    _.concat(["Z"], dataList),
    R.prepend("Z", dataList));

console.log("concat:", // => [ 'A', 'B', 'C', 'D', 'E', 'X', 'Y' ]
    dataList.concat(["X", "Y"]),
    _.concat(dataList, ["X", "Y"]),
    R.concat(dataList, ["X", "Y"]));

console.log("first:", // => A
    dataList[0],
    _.head(dataList), // _.first(dataList)もあり
    R.head(dataList));

console.log("last:", // => E
    dataList[dataList.length - 1],
    _.last(dataList),
    R.last(dataList));

console.log("rest:", // => [ 'B', 'C', 'D', 'E' ]
    dataList.slice(1),
    _.tail(dataList), // _.drop(dataList)もあり。_.restは別の関数
    R.tail(dataList));

console.log("take:", // => [ 'A', 'B' ]
    dataList.slice(0, 2),
    _.take(dataList, 2),
    R.take(2, dataList));

console.log("drop:", // => [ 'C', 'D', 'E' ]
    dataList.slice(2),
    _.drop(dataList, 2),
    R.drop(2, dataList));

console.log("take-last:", // => [ 'D', 'E' ]
    dataList.slice(-2),
    _.takeRight(dataList, 2),
    R.takeLast(2, dataList));

console.log("drop-last:", // => [ 'A', 'B', 'C' ]
    dataList.slice(0, -2),
    _.dropRight(dataList, 2),
    R.dropLast(2, dataList));

console.log("take-while:", // => [ 'A', 'B' ]
    _.takeWhile(dataList, (data) => data !== "C"),
    R.takeWhile((data) => data !== "C", dataList));

console.log("drop-while:", // => [ 'C', 'D', 'E' ]
    _.dropWhile(dataList, (data) => data !== "C"),
    R.dropWhile((data) => data !== "C", dataList));

filter/map/reduceなど

このあたりまではJavaScript標準の関数でもわりといけます。

main.ts
const f = (data: string) => data === "C" || data === "D";
console.log("find:", // => C
    dataList.find(f),
    _.find(dataList, f),
    R.find(f, dataList));

console.log("some:", // => true
    dataList.some(f),
    _.some(dataList, f),
    R.any(f, dataList));

console.log("filter:", // => [ 'C', 'D' ]
    dataList.filter(f),
    _.filter(dataList, f),
    R.filter(f, dataList));

console.log("filterNot/remove:", // => [ 'A', 'B', 'E' ]
    dataList.filter((data) => !f(data)),
    _.reject(dataList, f),
    R.reject(f, dataList));

const mf = (data: string) => data === "C" ? null : "X" + data;
console.log("map:", // => [ 'XA', 'XB', null, 'XD', 'XE'
    dataList.map(mf),
    _.map(dataList, mf),
    R.map(mf, dataList));

console.log("mapNotNull/keep:", // => [ 'XA', 'XB', 'XD', 'XE' ]
    dataList.map(mf).filter((data) => data != null),
    _(dataList).map(mf).without(null).value(),
    R.without([null], R.map(mf, dataList))); // R.pipe(R.map(mf), R.without<string|null>([null]))(dataList)

const mf2 = (data: string, i: number) => data + i;
console.log("map-indexed:", // => [ 'A0', 'B1', 'C2', 'D3', 'E4' ]
    dataList.map(mf2),
    _.map(dataList, mf2),
    R.addIndex(R.map)(mf2 as any, dataList)); // TypeScriptでは<any>でないとエラーになることがある(2018/04現在)

const mf3 = (data: string) => [data, data];
console.log("flatMap/mapcat:", // => [ 'A', 'A', 'B', 'B', 'C', 'C', 'D', 'D', 'E', 'E' ]
    _.flatMap(dataList, mf3),
    R.chain(mf3, dataList));

const nested = [1, [[2]], [[[3]]]];
console.log("flatten(一段階):", // => [ 1, [ 2 ], [ [ 3 ] ] ]
    _.flatten<any>(nested), // TypeScriptでは<any>でないとエラーになることがある(2018/04現在)
    R.unnest(nested));

console.log("flatten:", // => [ 1, 2, 3 ]
    _.flattenDeep<any>(nested), // TypeScriptでは<any>でないとエラーになることがある(2018/04現在)
    R.flatten<any>(nested)); // TypeScriptでは<any>でないとエラーになることがある(2018/04現在)

const rf = (acc: string, val: string) => acc + val;
console.log("reduce(初期値なし):", // => ABCDE
    dataList.reduce(rf),
    _.reduce(dataList, rf),
    R.reduce(rf, R.head(dataList)!, R.tail(dataList)));

console.log("reduce(初期値あり):", // => XABCDE
    dataList.reduce(rf, "X"),
    _.reduce(dataList, rf, "X"),
    R.reduce(rf, "X" as string, dataList));

console.log("reduce(途中でbreak):", // => YABC
    R.reduce((acc, val) => {
        if (acc.length > 3) {
            return R.reduced(acc);
        }
        return acc + val;
    }, "Y", dataList));

console.log("zip/interleave:",
    // [ 'XX', 'A', 99 ], [ 'YY', 'B', 88 ], [ undefined, 'C', undefined ], ... ]
    _.zip(["XX", "YY"], dataList, [99, 88]), // 最も長いリストと同じ長さになる(存在しない要素はundefinedに)
    // [ [ 'A', 99 ], [ 'B', 88 ] ]
    R.zip(dataList, [99, 88])); // 2つまでしか指定できない

高度なリスト操作

groupByはよく使うので標準でほしいですね。

main.ts
const users = [
    { user: "fred", age: 48 },
    { user: "barney", age: 36 },
    { user: "fred", age: 40 },
];
console.log("sort-by:", // => [ { user: 'barney', age: 36 }, { user: 'fred', age: 48 }, { user: 'fred', age: 40 } ]
    _.sortBy(users, ["user"]),
    _.sortBy(users, (item) => item.user),
    R.sortBy(R.prop("user") as any, users),
    R.sortBy((item) => item.user, users));

console.log("group-by:",
    // => { '3': [ { user: 'barney', age: 36 } ], '4': [ { user: 'fred', age: 48 }, { user: 'fred', age: 40 } ] }
    _.groupBy(users, (item) => Math.floor(item.age / 10)),
    R.groupBy((item) => String(Math.floor(item.age / 10)), users));

console.log("frequencies:", // => { '3': 1, '4': 2 }
    _.countBy(users, (item) => Math.floor(item.age / 10)),
    R.countBy((item) => Math.floor(item.age / 10), users));

const dupList = [1, 1, 2, 1];
console.log("distinct:", // => [ 1, 2 ]
    _.uniq(dupList),
    R.uniq(dupList));

console.log("dedupe:", // => [ 1, 2, 1 ]
    _.reduce(_.tail(dupList), (acc, val) => val !== _.last(acc) ? _.concat(acc, val) : acc, [_.head(dupList)]),
    R.dropRepeats(dupList));

console.log("shuffle:", // => [ 'D', 'B', 'A', 'C', 'E' ]
    _.shuffle(dataList));

console.log("interpose:", // => [ 'A', '$', 'B', '$', 'C', '$', 'D', '$', 'E' ]
    R.intersperse("$", dataList));

console.log("reductions:", // => [ 2, 6, 24, 120 ]
    R.scan((acc, val) => acc * val, 2, [3, 4, 5]),
    R.scan(R.multiply as any, 2, [3, 4, 5])); // TypeScriptではas anyにしないとエラーになる

console.log("partitionAll:", // =>  [ [ 'A', 'B' ], [ 'C', 'D' ], [ 'E' ] ]
    _.chunk(dataList, 2),
    R.splitEvery(2, dataList));

オブジェクトに対する操作

複雑なことをしようとすると、toPairs(), fromPairs()のお世話になります。

main.ts
let dataMap = { x: "x1", y: { y1: 1, y2: 2 }, z: "Z" };
console.log("get-in:", // => 2
    _.get(dataMap, "y.y2"),
    _.get(dataMap, ["y", "y2"]),
    R.path(["y", "y2"], dataMap));

console.log("assoc-in:", // => { x: 'x1', y: { y1: 1, y2: 2, y3: 33 }, z: 'Z' }
    _.set(dataMap, "y.y3", 33), // dataMapは変更される
    _.set(dataMap, ["y", "y3"], 33), // dataMapは変更される
    R.assocPath(["y", "y3"], 33, dataMap)); // dataMapは変更されない
dataMap = { x: "x1", y: { y1: 1, y2: 2 }, z: "Z" };

console.log("dissoc-in:", // => { x: 'x1', y: { y1: 1 }, z: 'Z' }
    _.omit(dataMap, "y.y2"), // dataMapは変更されない
    _.unset(dataMap, "y.y2"), // dataMapは変更される
    (() => { dataMap = { x: "x1", y: { y1: 1, y2: 2 }, z: "Z" }; })(),
    R.dissocPath(["y", "y2"], dataMap)); // dataMapは変更されない

console.log("update-in:", // => { x: 'x1', y: { y1: 1, y2: 4 }, z: 'Z' }
    _.update(dataMap, "y.y2", (data) => data * data),  // dataMapは変更される
    (() => { dataMap = { x: "x1", y: { y1: 1, y2: 2 }, z: "Z" }; })(),
    R.over(R.lensPath(["y", "y2"]), (data: number) => data * data, dataMap)); // dataMapは変更されない

console.log("select-keys:", // => { x: 'x1', z: 'Z' }
    _.pick(dataMap, ["x", "z"]),
    R.pick(["x", "z"], dataMap));

console.log("entries:", // => [ [ 'x', 'x1' ], [ 'y', { y1: 1, y2: 2 } ], [ 'z', 'Z' ] ]
    Object.entries(dataMap),
    _.entries(dataMap),
    _.toPairs(dataMap),
    R.toPairs(dataMap));

// forEach
_.forIn(dataMap, (value, key) => console.log(key + ":" + value));
R.forEachObjIndexed((value, key) => console.log(key + ":" + value), dataMap);

console.log("mapValues:", // => { x: 'x#x1', y: 99, z: 'z#Z' }
    _.mapValues(dataMap, (value, key) => typeof (value) === "string" ? `${key}#${value}` : 99),
    R.mapObjIndexed((value, key) => typeof (value) === "string" ? `${key}#${value}` : 99, dataMap));

console.log("Objectに対するmap:", // => { '$x': 'x', '$y': 'y', '$z': 'z' }
    _(dataMap).toPairs().map(([key, value]) => ["$" + key, key]).fromPairs().value(),
    R.fromPairs(R.map(([key, value]) => ["$" + key, key], R.toPairs(dataMap)) as any));

console.log("merge:", // => { x: 'x1', y: 'Y', z: 'Z', zz: 'ZZ' }
    Object.assign(dataMap, { y: "Y", zz: "ZZ" }), // dataMapは変更される
    _.assign(dataMap, { y: "Y", zz: "ZZ" }), // dataMapは変更される
    R.merge(dataMap, { y: "Y", zz: "ZZ" })); // dataMapは変更されない
dataMap = { x: "x1", y: { y1: 1, y2: 2 }, z: "Z" };

console.log("mergeDeep:", // => { x: 'x1', y: { y1: 'YYY', y2: 2 }, z: 'Z' }
    _.merge(dataMap, { y: { y1: "YYY" } }), // dataMapは変更される
    (() => { dataMap = { x: "x1", y: { y1: 1, y2: 2 }, z: "Z" }; })(),
    R.mergeDeepRight(dataMap, { y: { y1: "YYY" } })); // dataMapは変更されない

console.log("merge-with:", // => { x: 'x1X', y: { y1: 1, y2: 2 }, z: 'Z' }
    _.mergeWith(dataMap, {x: "X"}, (v1, v2) => v1 + v2), // dataMapは変更される
    (() => { dataMap = { x: "x1", y: { y1: 1, y2: 2 }, z: "Z" }; })(),
    R.mergeWith((v1: string, v2: string) => v1 + v2, dataMap, {x: "X"})); // dataMapは変更されない

console.log("zipmap:", // => { a: 1, b: 2 }
    _.zipObject(["a", "b"], [1, 2]),
    R.zipObj(["a", "b"], [1, 2]));

一つひとつの関数は単純でも、それを組みわせると高度なことが簡単にできるようになるのがLodashやRamdaのいいところですね。