実例 is() / TypeScript一人カレンダー
こんにちは、クレスウェア株式会社の奥野賢太郎 (@okunokentaro) です。本記事はTypeScript 一人 Advent Calendar 2024の9日目です。昨日は『実例 ExtractKeyOf』を紹介しました。
typeプロパティを持つオブジェクトとDiscriminated Union
Web開発で扱うデータ構造の中には、type
プロパティを持ってユニオン型として表現するパターンがよくあります。たとえば、以下のような「画像」と「動画」という2種類のデータを識別するためにtype
キーを使う例が典型です。
type Image = Readonly<{
type: "image";
path: string;
}>;
type Video = Readonly<{
type: "video";
path: string;
thumbnailPath: string;
}>;
このほかにも、errorCode
の値に基づいてerrorData
の構造が変化するエラーハンドリングだったり、active
やwaiting
などの値を持つ非同期のステート管理など、一部の値を判別して処理を分岐させるケースはWebアプリケーションにおいて頻繁に登場します。
このような構造は「Discriminated Union(判別可能な共用体)」と呼ばれます。TypeScriptでは、このようなユニオン型を安全に絞り込み、特定の型に絞るための「Type Predicate Signature」を過去のアドベントカレンダーでも紹介してきました。
型ガード関数の量産からの卒業
Image
かVideo
かを判定するために、isImage()
関数やisVideo()
関数を個別に定義することはよくあります。ですが、typeが増えるごとに関数を増やさなければならず、種類が多いモデルを扱うときに煩雑になります。似たような判定関数が何個も乱立すると、DRY違反を気にし始めることになりますし、コードベースが次第に見通しづらくなってしまいます。
そこで、型ガードロジックを一箇所にまとめて抽象化した関数is()
を作ることができます。is()
はtype
プロパティで判別するUnion型に対して、指定したtype
値と合致するアイテムであるかをチェックし、合致すれば該当部分型(Extract
型を用いる)として絞り込む型ガードを提供します。
function is<T extends Image | Video, U extends T["type"]>(
type: U,
item: T,
): item is Extract<T, { type: U }> {
return item.type === type;
}
これは、プロジェクト内すべてのありとあらゆるtype
を受け入れるわけではなく、あくまでもT extends Image | Video
の範囲に限ってのものです。
汎用的なis()関数による絞り込み
たとえば、getThumbnail()
関数でImage
とVideo
を判定してサムネイル用のパスを取得するとします。
function getThumbnail(item: Image | Video): string {
if (is("image", item)) {
// item は Image として扱える
return item.path;
}
if (is("video", item)) {
// item は Video として扱える
return item.thumbnailPath;
}
throw new Error("invalid item type");
}
const imageItem = {
type: "image",
path: "path/to/image.jpg",
} as const;
const videoItem = {
type: "video",
path: "path/to/video.mp4",
thumbnailPath: "path/to/video-thumb.jpg",
} as const;
console.log(getThumbnail(imageItem)); // "path/to/image.jpg"
console.log(getThumbnail(videoItem)); // "path/to/video-thumb.jpg"
is("image", item)
やis("video", item)
で絞り込むことによって、コンパイラはitem
を正しくImage
かVideo
として扱えるため、それぞれの型固有のプロパティ(path
やthumbnailPath
)に型エラーなしでアクセスできます。ですが、これはisImage()
やisVideo()
を毎回実装するのとそこまで大差なさそうにみえます。type
の数に関わらずis()
を実装した方が優れている点を確認しましょう。
なぜis()が有効なのか
今回の例はわずか2種類の型 (Image
とVideo
) しかないため、isImage()
とisVideo()
を個別に定義する方が単純に見えるかもしれません。しかし、もし10種類、20種類とtype
値が増え、isSomething()
関数が乱立しそうな場合、一度is()
関数で抽象化しておくと、関数定義の数や重複を大幅に減らすことができます。
さらに、is()
による絞り込みは、Narrowing(型絞り込み)によってIDE補完を改善し、不要な関数の候補を減らします。is("image", item)
を記述した後は、以後の補完に"image"
が出現しないなど、使い心地の面でも恩恵があります。これはisImage()
関数を実装してしまっては恩恵が受けられず、毎回すべての判別関数が補完の候補に上ってきます。そのため、種類が多くなかろうともis()
関数はNarrowingの恩恵を受ける観点で優れています。
このようなささやかなユーティリティ関数を実装する際は、extends
などの構文を工夫することで、このように汎用型ガードが書ける点も覚えておくと便利です。item is Extract<T, { type: U }>
という書き方も細かなTypeScriptテクニックの積み重ねによって成り立っています。
まとめ
TypeScriptでは細やかな型操作のテクニックを積み重ねることで、より安全で快適な開発体験を築けます。is()
関数のようなユーティリティ関数を実装することで、Discriminated Unionを扱う際の型ガード関数の量産を防ぎ、Narrowingを導入し柔軟かつ使いやすい環境を構築できます。昨日までの記事でしばしば触れたts-essentialsのようなユーティリティ集もとても便利ですが、ちょっとした開発の手間をさっと助けられるようなTypeScriptスキルを磨いておくと、きっと開発がより捗るようになります。
明日は『実例 hooksTestingTools()』
本日は「is()」を紹介しました。明日は「実例 hooksTestingTools()」を紹介します。それではまた。
Discussion