実例 mustFind() / TypeScript一人カレンダー
こんにちは、クレスウェア株式会社の奥野賢太郎 (@okunokentaro) です。本記事はTypeScript 一人 Advent Calendar 2024の13日目です。昨日は『satisfies』を紹介しました。
strictNullChecksの便利さと不便さ
strictNullChecksはTypeScript 2.0で導入され、strict: trueを有効にしていれば自動的にセットされる、非常に有名なオプションです。もしstrictNullChecksを知らなくても、あなたのプロジェクトがstrict: trueであれば問題ありません。strictなしでTypeScript開発を続けることは非常におすすめできない選択なため、もしstrictがfalseならば今すぐtrueに切り替えましょう。
とはいえ、strictNullChecksは便利なのですが、手間に感じてしまう場面もどうしてもでてきます。
Array.prototype.find()がundefinedを返しうることに対して対処が必要になるのは有名な問題です。必ず存在すると分かっている場合でも、毎回結果に対してundefinedの可能性を考慮しなければなりません。これについて、たとえば過去のカレンダーではassertExists()の1行を追加することでチェックする方法を紹介しましたが、それでも大量のfind()があるとやっぱり手間となります。
外部APIなどから取得した、自分たちの用途に最適化されていない巨大なツリー状のデータを何度もfind()で走査する場合、assertExists()の1行も積み重なって煩雑に思えることもあるでしょう。
mustFind() の実装例
そこでassertExists()を内包したmustFind()関数を作れば、毎回undefinedチェックを書かずに済みます。
import { assertExists } from "./assert-exists";
export function mustFind<T, S extends T>(
arr: T[] | readonly T[],
cb: (value: T, index: number, arr: readonly T[] | T[]) => value is S,
): S;
export function mustFind<T>(
arr: T[] | readonly T[],
cb: (value: T, index: number, arr: readonly T[] | T[]) => boolean,
): T;
export function mustFind<T>(
arr: T[] | readonly T[],
cb: (value: T, index: number, arr: readonly T[] | T[]) => unknown,
): unknown {
const found = arr.find(cb);
assertExists(found);
return found;
}
このmustFind()はArray.prototype.find()の結果がundefinedである可能性を排除します。assertExists()によるチェックにより、見つからなければErrorをthrowし、呼び出し側は「必ず見つかる」ことを前提に後続処理を書けるようになります。
オーバーロードでType predicate signatureにも対応する
ここでサンプルコードでは、export function mustFind()が三回も書かれている点に注目できます。
mustFind()実装時、最初はbooleanを返すコールバックだけに対応して実装しました。しかし、find()ではType predicate signature (value is S)を返すコールバックにも対応すればNarrowingの恩恵を享受できます。これを無視するとmustFind()はfindと同時に型の絞り込みができず、実務用途としては片手落ちになってしまいます。
しかし、value is Sのみを実装すると、今度はbooleanだけを単に返したい場合にエラーとなってしまい困るため、両方のシグネチャをサポートしなければなりません。ここでFunction overloadsを思い出せれば、よりTypeScriptに熟練していると言えるでしょう。
上記のサンプルコードでは、mustFind()は3つのシグネチャを持ち、最初の2つはオーバーロードシグネチャを宣言、最後が本体という構成です。ここで最後にunknownを使うのは、本体で1つめと2つめの異なるシグネチャを両立させるための妥協的な宣言です。ですが実際使ってみるとunknownが返却されるような実害になることはありません。
使い方は簡単で、Array.prototype.find()を置き換えるだけです。
// 今まではこういった関数をドメインモデルごとに作って対応していた。
function findItem(items: readonly Item[], itemId: ItemId): Item {
const item = items.find(v => v.id === itemId);
assertExists(item);
return item;
}
const found = findItem(items, itemId);
// 今後はもうドメインモデルごとに毎回ラッパーを作る必要がない。
const found = mustFind(items, (v) => v.id === itemId);
if (found === undefined) { ... }やassertExists()が大量に並ぶのも煩雑ですし、findUser()やfindItem()などのラッパー関数を毎回実装するのも手間です。mustFind()ひとつで安全性と記述性がぐっと上がります。
次のコードのようにDestructuring assignmentを活用できるというのもnon-nullが保証されるという大きなメリットとなります。
const { name, price } = mustFind(items, (v) => v.id === itemId);
このように安全かつ簡潔にnull checkが済む工夫を増やすと、読みやすいコードが増えていくことでしょう。
テストコードでの確認
mustFind()のテストを書いて、Narrowingが期待通り機能することを確認しましょう。Type predicate signatureの対応やreadonly配列にも対応したテストを作成しておくことで、その安全性が伝わります。
import { describe, expect, test } from "vitest";
import { PreconditionError } from "./precondition-error";
import { mustFind } from "./must-find";
describe("mustFind()", () => {
describe("number[]", () => {
test("cb で true を返す項目が見つかれば得る", () => {
const arr = [0, 1, 2];
const found = mustFind(arr, (v) => v === 1);
expect(found).toEqual(1);
});
test("cb で true を返す項目がなければ例外", () => {
const arr = [0, 1, 2];
expect(() => mustFind(arr, (v) => v === 3)).toThrow(PreconditionError);
});
});
describe("readonly number[]", () => {
test("cb で true を返す項目が見つかれば得る", () => {
const arr = [0, 1, 2] satisfies readonly number[];
const found = mustFind(arr, (v) => v === 1);
expect(found).toEqual(1);
});
test("cb で true を返す項目がなければ例外", () => {
const arr = [0, 1, 2] satisfies readonly number[];
expect(() => mustFind(arr, (v) => v === 3)).toThrow(PreconditionError);
});
});
describe("string[]", () => {
test("cb で true を返す項目が見つかれば得る", () => {
const arr = ["a", "b", "c"];
const found = mustFind(arr, (v) => v === "b");
expect(found).toEqual("b");
});
test("cb で true を返す項目がなければ例外", () => {
const arr = ["a", "b", "c"];
expect(() => mustFind(arr, (v) => v === "d")).toThrow(PreconditionError);
});
});
describe("Discriminated union の Narrowing に対応している", () => {
type A = { type: "a"; a: string };
type B = { type: "b"; b: string };
function acceptA(v: A): string {
return v.a;
}
function acceptB(v: B): string {
return v.b;
}
describe("read write", () => {
const arr = [
{ type: "a", a: "A" },
{ type: "b", b: "B" },
] satisfies (A | B)[];
test("case 0", () => {
const found = mustFind(arr, (v): v is A => v.type === "a");
expect(acceptA(found)).toEqual("A");
});
test("case 1", () => {
const found = mustFind(arr, (v): v is B => v.type === "b");
expect(acceptB(found)).toEqual("B");
});
});
describe("readonly", () => {
const arr = [
{ type: "a", a: "A" },
{ type: "b", b: "B" },
] satisfies readonly (A | B)[];
test("case 0", () => {
const found = mustFind(arr, (v): v is A => v.type === "a");
expect(acceptA(found)).toEqual("A");
});
test("case 1", () => {
const found = mustFind(arr, (v): v is B => v.type === "b");
expect(acceptB(found)).toEqual("B");
});
});
});
});
このテストでは、number[]やstring[]、そしてDiscriminated unionな構造を持つ配列を渡した場合の対応などを確認し、mustFind()がundefinedを返さないことやNarrowingが正しく働いていることを確かめられます。
TypeScriptテクニックを実務に活かす
Function overloadingはWebアプリケーション開発においては毎日使うようなテクニックではないため、積極的に使わないと忘れがちなのですが、mustFind()のような抽象度の高いユーティリティ関数を開発する際には思い出す価値があります。記述に癖があり、抽象度の高い型パラメータを組み合わせるため、最初はコンパイルエラーを連発しがちで実装は大変ですが、成功するとその恩恵は大きいです。
こういったTypeScriptが提供するAPIやテクニックを片っ端から実務に投入しながら経験を積むことで、TypeScriptの上達に繋がります。ぜひ今回のmustFind()の実例をヒントに、あなたなりの工夫を凝らしてみてください。
明日は『Vitest test-d.ts で複雑な型をテストする』
本日は『実例 mustFind()』を紹介しました。明日は『Vitest test-d.ts で複雑な型をテストする』を紹介します。それではまた。
Discussion