TypeScript/JavaScript

JavaScriptのArray#Reduceの関数内で第二引数の変数を利用した場合エラーになるESLintのルール

ハローワールド。

JavaScript/TypeScriptに関わらず、現在のモダン言語には配列の操作の関数/メソッドとして reduce が存在します。

reduce の解説は至るところにありますので省くとして、最近僕がよくreduce でハマっているポイントが有り、それを解消するためのESLintのルールを作成しました。

Reduceにおけるミス

下記のようなモデルのデータがあるとします。現実的な例ではありませんが、動物園の名簿管理とします。動物園にはcatliontigerがいて、それぞれの動物の名前が配列で付いているとします。

type AnimalNames = { cat: string[], lion: string[], tiger: string[], } const animalNames: AnimalNames = { cat: ["タマ", "ポチ"], lion: [] tiger: ["トラッキー"] }

この名前を追加・削除をする共通処理として下記のような関数があるとします。

const addName = (animalNames: AnimalNames, target: "cat" | "lion" | "tiger", name: string): AnimalNames => { return { ...animalNames, [target]: animalNames[target].concat(name), } }

この時、不特定多数の動物・名前を一気に追加したい処理が発生しました。reduceを利用して下記のように実装しました。

type Info = { target: "cat" | "lion" | "tiger"; name: string; } const addNames = (animalNames: AnimalNames, data: Info[]) => { data.reduce((accAnimalNames, addInfo) => { return addName(animalNames, addInfo.target, addInfo.name); }, animalNames); }

さて、上記のreduceなのですが処理が間違っています。

下記のように処理を行ったところ、下記のような結果となってしまいました。catに「ハチ」を追加したかったのですが、ハチが追加されていません。

const newNames = addNames( animalNames, [ { target: "cat", name: "ハチ" }, { target: "lion", name: "リオン" } ] ) console.log(newNames); // => { // cat: ["タマ", "ポチ"], // ← "ハチ" が追加されていない // lion: ["リオン"], // tiger: ["トラッキー"] // }

原因は下記の通り、accAnimalNamesを使うべきところにanimalNamesを使ってしまっていることです。すなわち、初期値に対して毎回更新しており、それ以前の更新処理はすべて失われてしまっています。

const addNames = (animalNames: AnimalNames, data: Info[]) => { data.reduce((accAnimalNames, addInfo) => { // animalNamesに値を追加している。本来ならaccAnimalNamesに値を追加しなければならない return addName(animalNames, addInfo.target, addInfo.name); }, animalNames); }

上記に関してはモデルが悪いとか操作の方法が悪いとかあると思います。また、単純にreduceを使うのが悪いということで、reduceを禁じるlintなんかもあったりします。for-ofとかで代用できるよね、ということで。

しかし、個人的にはreduceはあんまり禁じたくないです。なので、「reduceの第二引数に指定された初期値を、第一引数の関数内で利用されたら問題とする」というlintを作成すれば上記を回避できるのでは? と考え、今回lintを作成しました。

Reduceの第二引数に指定された初期値を、第一引数の関数内で利用されているかを検知するルール

上記の問題を検知するためのルールを作成したのが下記です。ちなみに、下記をインストールしても、特にESLintで動くとかない(そもそもライブラリを公開していない)ので、あくまでコードの参考となれば。

https://github.com/sa2taka/no-invalid-reduce-variable-eslint-rule

上記のコードのテスト[1]を確認すれば、どの場合にエラーになるかなどがわかります。

Fail

第一引数が関数内で利用される場合はエラーになります。

arr.reduce(function(acc) { return count }, count)

第二引数が配列でも動作します。

arr.reduce(function(acc) { return [count1, count2] }, [count1, count2])

第二引数がオブジェクトの場合は、オブジェクトの値に指定されている変数が利用されていないかを確認します。キーは当然ながら確認しません。

arr.reduce(function(acc) { return { count: count1 } }, { count: count1 })

第二引数のプロパティを参照する場合も問題とします。

arr.reduce(function(acc) { return obj.count }, obj)

第二引数がプロパティ参照(Member Expression)の場合は、Member Expression全体が一致しているかを確認します

arr.reduce(function(acc) { return obj.count }, obj.count)

Success

通常通りやれば特に問題ないです。

arr.reduce(function(acc) { acc }, count);

第二引数がリテラルな場合、同じリテラルを利用しても特に問題ないです。

arr.reduce(function(acc) { return acc + 1 }, 1);

第二引数がオブジェクトの場合、オブジェクトのキーなどで検知されることはありません。

arr.reduce(function(acc) { return { count: acc } }, { count: count1 });

第二引数と、第一引数の関数の引数が同じ場合、特にエラーとしません。

arr.reduce(function(count) { return count }, count);

第二引数がMember Expressionの場合、第二引数のMemberExpressionのオブジェクトを利用していても特に問題ありません。

arr.reduce(function(acc) { return obj }, obj.count);

第二引数がMember Expressionの場合、第二引数のMemberExpressionのプロパティと同名の変数を利用していても特に問題ありません。

arr.reduce(function(acc) { return count }, obj.count);

第二引数が空の配列で、関数内で空の配列を利用していても問題ありません。

arr.reduce(function(acc) { return [] }, []);

第二引数が空のオブジェクトで、関数内で空のオブジェクトを利用していても問題ありません。

arr.reduce(function(acc) { return {} }, {});

ルールを適用

ルールを適用した上で、上のaddNamesにlintをかけて見ましょう。

addNames lint error

作成ノート

今回ルールを作成する上で、若干困ったもののメモです。

下記は前提として、Custom ESLint RuleをTypeScriptで作りたいの内容が含まれています。この記事はESLintのルールを作成する手順みたいなものです。

子供を再帰的に探索する方法がわからなかった

上記のルールを作成する際ですが、一番困ったのが再帰的に子供を読んでいく動作です。
今回はCallExpressionをキャッチして検知するのですが、第一引数の関数を掘っていき、変数名などを取得していく方法がわかりませんでした。

色々調べると、その動作のことをvisitと呼ぶことがわかりましたが、それ用のライブラリがあんまりありませんでした。パッと見つかった処理参考にして、完成したのが下記です。

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
import { Node } from "estree"; export type Visitor = (node: Node) => typeof SKIP | void; export const SKIP = Symbol("skip"); const isNodeLike = (value: unknown): value is Node => { return Boolean( value && typeof value === "object" && // @ts-expect-error Looks like a node. typeof value.type === "string" && // @ts-expect-error Looks like a node. value.type.length > 0 ); }; export const visit = (node: Node, visitor: Visitor): void => { const result = visitor(node); if (result === SKIP) { return; } for (const key in node) { const typedKey = key as keyof Node; if (node[typedKey] && typeof node[typedKey] === "object" && key !== "parent") { const value = node[typedKey]; if (Array.isArray(value)) { for (const element of value) { if (isNodeLike(element)) { const result = visitor(element); if (result === SKIP) { continue; } visit(element, visitor); } } } else { if (isNodeLike(value)) { const result = visitor(value); if (result === SKIP) { continue; } visit(value, visitor); } } } } };

動作を見るとすごーく単純で、for-inを利用し、対象のプロパティをすべて調べます。その中でNodeっぽい値(具体的にはオブジェクトでプロパティtypeが空文字以外の文字列の場合)に関して、同じことを行います。ESLintの場合は特殊でparentに親の情報が詰まっているので、それを省いています。


  1. テストコードがarrow function(() => { })でなくて普通の匿名関数(function () { })を利用している理由は、なぜかarrow functiondだとテストが動かないからです。lintとして動かす場合は問題なく動きます ↩︎