🎄

実例 is() / TypeScript一人カレンダー

2024/12/19に公開

こんにちは、クレスウェア株式会社の奥野賢太郎 (@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の構造が変化するエラーハンドリングだったり、activewaitingなどの値を持つ非同期のステート管理など、一部の値を判別して処理を分岐させるケースはWebアプリケーションにおいて頻繁に登場します。

このような構造は「Discriminated Union(判別可能な共用体)」と呼ばれます。TypeScriptでは、このようなユニオン型を安全に絞り込み、特定の型に絞るための「Type Predicate Signature」を過去のアドベントカレンダーでも紹介してきました。

型ガード関数の量産からの卒業

ImageVideoかを判定するために、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()関数でImageVideoを判定してサムネイル用のパスを取得するとします。

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を正しくImageVideoとして扱えるため、それぞれの型固有のプロパティ(paththumbnailPath)に型エラーなしでアクセスできます。ですが、これはisImage()isVideo()を毎回実装するのとそこまで大差なさそうにみえます。typeの数に関わらずis()を実装した方が優れている点を確認しましょう。

なぜis()が有効なのか

今回の例はわずか2種類の型 (ImageVideo) しかないため、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