この記事は、FIXER Advent Calendar 2021 18日目の記事です。
はじめに
お久しぶりです。佐野です。
最近業務でVue(Nuxt)を触っているんですが、TypeScriptで実装しているので型解決でちょくちょく詰まってしまうんですよね。
ただ、そこがTypeScriptの楽しい所でもあって、今まで適当な型で書いていたものをちょっと厳密に書いてみるだけで、一気にコードがスマートになったような気がします。まあ厳密に書きすぎると今度は複雑になり過ぎて可読性が著しく損なわれるので、そこらへんはバランスをとってやる必要がありますが…それらを十全に考慮して良いコードが書けたときはもう、計り知れない程の達成感があります。
さて、今回はreduceとgenericsを使って連想配列を作成する便利関数を作る話です。
なお、自分はTypeScriptのプロではないので、紹介するサンプルコードはベストプラクティスではない可能性が高いということにご注意ください。
連想配列
自分はVueを書いているとき、よく { [key: number]: 何かしらのinterface[] }
みたいな感じで、連想配列でグループ分けした配列を使っています。
例えば何かしらのリストがあって、それをフィルターとか、タブ分けとか、とにかくUI上でグループ分けして表示しなければならない場合です。
グループごとにAPIを叩くなら、その選択肢をdataに保持しておいて値が変わる度にAPIからフィルタ済みのリストを取ってくればいいと思います。しかし、リストの要素数がそんなに大きくない想定の場合は、最初にAPIから全データを取ってきてそれをフロント側でフィルタするということもあるかと思います。
その場合、computedで選択した値を監視して変わる度にリストをフィルタし直してもいいですが、それよりも最初にグルーピングして連想配列に入れておき、選択した値で参照するようにした方が再計算も発生しないためエコです。
では、上のような連想配列を作るには、どうしたらいいでしょうか?
前提条件
まず、前提となるコードを置いておきます。Status
という状態を持った Item
のリストが apiResponse
として定義されていて、これを Status
ごとにグルーピングして連想配列( { [key in Status]: Item[] }
)を作ります。
const Status = {
First: 1,
Second: 2,
Third: 3
} as const;
type Status = typeof Status[keyof typeof Status]
interface Item {
id: number
status: Status
}
const apiResponse: Item[] = [{ id: 1, status: 1 }, { id: 2, status: 1 }, { id: 3, status: 2 }, { id: 4, status: 3 }, { id: 5, status: 3 }];
これ以降のサンプルコードは、全てこのコードの下に書かれています。
(因みにサンプルコードのテスト環境はこの記事を参考にさせていただきました。TSでも結構簡単に実行できるんですね…)
Reduceで連想配列を作る
forを使うやり方は自分で考えてもらうとして、まずは普通のreduceを使ったやり方を紹介します。
const groupedItemList = apiResponse.reduce((acc, item) => {
if (acc[item.status]) {
acc[item.status].push(item);
} else {
acc[item.status] = [item];
}
return acc;
}, {} as { [key in Status]: Item[] });
console.log(groupedItemList);
reduceの初期値に空オブジェクトを渡し、ループ中にリストの有無を確認してあるならitemを追加して、なければリストを新規作成しています。
実行結果は以下のようになります。
$ npx ts-node index.ts
{
'1': [ { id: 1, status: 1 }, { id: 2, status: 1 } ],
'2': [ { id: 3, status: 2 } ],
'3': [ { id: 4, status: 3 }, { id: 5, status: 3 } ]
}
まあ、至極真っ当なreduceの使い方ですよね。
でも、何か「おかしい」と感じませんか?
Reduce ≒ For ループ?
「 forを使うやり方は自分で考えてもらう 」などと偉そうなことを言いましたが、実はほぼ答えは書いてあります。以下のコードをご覧ください。
let acc = {} as { [key in Status]: Item[] };
for (const item of apiResponse) {
if (acc[item.status]) {
acc[item.status].push(item);
} else {
acc[item.status] = [item];
}
}
const groupedItemList = acc;
console.log(groupedItemList);
ループの内側で違うのはreturnの有無くらいで、それ以外は全て同じなのが分かると思います。つまり、reduceを書くということは、forを書くということと(見た目上は)ほぼ同義ということです。
なんということでしょう。forよりスマートに書けると思っていたのに!
タイトル回収
ここで、ようやくこの記事のタイトルを思い出す時が来ました。
「Reduceで自分だけの高階関数を作ろう!.ts」
reduceでスマートに書けないなら、もっと使いやすい高階関数を自分でつくればいいじゃないかと。
では、作ってみましょう。出来上がった関数が以下になります。
const groupBy = <T, K extends number | string, V>(
list: T[],
func: (value: T, index: number) => undefined | [K, V]
): { [key in K]: V[] } => {
return list.reduce((acc, value, index) => {
const result = func(value, index);
if (result) {
const [newKey, newValue] = result;
if (acc[newKey]) {
acc[newKey].push(newValue);
} else {
acc[newKey] = [newValue];
}
}
return acc;
}, {} as { [key in K]: V[] });
};
reduceをベースにして、普遍的に使えるようにgenericsを使用しています。callback関数の func
はリストの各要素を引数に取ってkeyとvalueのtupleを返すものです。何も返さなければ、その要素は無視されます。
以下のように使用します。
const groupedItemList = groupBy(apiResponse, item => [item.status, item]);
console.log(groupedItemList);
単純明快に、一行で書けるようになりましたね!
これぞ自分の求めていた高階関数です。
皆さんも(チームの方針的に大丈夫そうであれば)自分なりの便利な高階関数を作ってみてはいかがでしょうか?
※実際のプロジェクトで使用する場合は適切な関数名と適切なコメントを心掛け、他人が見て読めないような関数はできるだけ書かないようにしましょう。
余談:const 教の皆様へ
pushやaccのプロパティの再代入を見て鳥肌が立ってしまった人向けに、それっぽいものを置いておきます。ご査収ください。
const groupBy = <T, K extends number | string, V>(
list: readonly Readonly<T>[],
func: (value: Readonly<T>, index: number) => undefined | readonly [K, Readonly<V>]
): Readonly<{ [key in K]: V[] }> => {
return list.reduce((acc, value, index: number) => {
const result = func(value, index);
if (result) {
const [newKey, newValue] = result;
return { ...acc, [newKey]: (acc[newKey] ? acc[newKey].concat(newValue) : [newValue]) };
} else {
return acc;
}
}, {} as Readonly<{ [key in K]: V[] }>);
};