🤔

TypeScript ユーザー定義型ガード基礎理解

に公開

はじめに

TypeScriptを学んでいて、ユーザー定義型ガードの部分で理解に躓いたので、記事にまとめました。

この記事の対象者

・TypeScriptを学び始めた人
・ユーザー定義型ガードの理解が浅い人
・ユーザー定義型ガードの使い時・必要性がわからない人

ユーザー定義型ガードとは

型ガードとは型を絞ることです。
以下のコード例のように、typeofやin演算子などを使って、値の型に応じて条件分岐させます。

  const n: unknown = "";
  if (typeof n === "string") {
    console.log("nはstring型です。");
  }
  if (typeof n === "number") {
    console.log("nはnumber型です。");
  }

ユーザー定義型ガードとは、開発者が型述語を使って関数の引数の型を絞ることです。
型述語は、引数 is 型名とします。返り値がtrueなら型名の型になります。

以下はユーザー定義型ガード例です。

  type User = {
    name: string;
    age: number;
  };

  const isUser = (obj: unknown): obj is User => {
    if (typeof obj !== "object" || obj === null) return false;

    const o = obj as Record<string, unknown>;
    return typeof o.name === "string" && typeof o.age === "number";
  };

  const obj: unknown = {
    name: "sato",
    age: 25,
  };

  if (isUser(obj)) {
    console.log(obj.name);
  }

上記のコード例では、引数にunknown型の値を渡しています。
また、obj is Userの部分が型述語であり、isUser関数の返り値がtrueのとき、引数のobjの型がUser型になります。
下記の部分では、objunknown型として定義しています。

  const obj: unknown = {
    name: "sato",
    age: 25,
  };

実際に型が絞られているのは以下の部分です。unknown型として定義されたobjですが、関数の宣言時に型述語によって型ガードされており、以下のブロックの中ではobjUser型に絞られます。

 if (isUser(obj)) {
    console.log(obj.name);
  }

ユーザー定義型ガードを使うとどのようなメリットがある?

まずTypeScriptの組み込みの型ガードでは、複雑なオブジェクトの型を適切に推論することができません。
そこで、ユーザー定義型ガードを使って型を絞り込み、そのブロック中の型を適切な方へ変更することができます。
そうすることによって以下のメリットが得られます。
型推論が効く
型安全性が増す。
ドキュメント代わりになる。

ただし基本的にユーザー定義型ガードも危険です。
なぜならTypeScriptの型推論に任せるのではなく、開発者自身が型を決めているからです。
このように開発者自身で型を決めてしまうと、そもそもの型の絞り方にミスがあってもTypeScriptは感知することができません。
そのため、基本的には危険ですが、どうしてもオブジェクトの型を絞ることがあれば、アサーションよりは安全なので、積極的に使ったほうがいいでしょう。

なぜ型アサーションのほうが危険かについてですが、型アサーションの場合は、強制的にTypeScriptに型の情報を信じ込ませているからです。「コンパイル時」には通るけど「実行時」に壊れる危険があります。一方でユーザー定義型ガードは、開発者が型を決めてはいるものの、実際の条件判定に基づいて型を確定することができます。条件に合わなければ、引数で指定した型になってくれます。

どのようなときにユーザー定義型ガードを使う?

ユーザー定義型ガードは、実行時に値の型を開発者が明示的に判定したいときに使います。
特に以下のようなケースで効果的です。
・APIレスポンスなど、実態のわからない外部データを扱うとき
typeofinでは絞り込みが難しい、複雑なオブジェクトの型を扱うとき
・TypeScriptの型推論では判断できないが、開発者が論理的に保証できる条件があるとき

つまり、TypeScriptコンパイラの型推論が届かない部分を、開発者自身が「安全に」助けてあげる仕組みです。
以下の例は、関数の引数に実態のよくわからないオブジェクトを入れる際の例です。

type Human = {
    type: "Human";
    name: string;
    age: number;
  };

  function isPropertyAccessible(
    value: unknown
  ): value is { [key: string]: unknown } {
    return value != null;
  }

  function isHuman(value: unknown): value is Human {
    // プロパティアクセスできない可能性を排除
    if (!isPropertyAccessible(value)) return false;
    // 3つのプロパティの型を判定
    return (
      value.type === "Human" &&
      typeof value.name === "string" &&
      typeof value.age === "number"
    );
  }

上記のコードを軽く補足すると、isHumanの引数であるvalueunknown型なので、value.typeのようにプロパティアクセスすることができません。
そこでisPropertyAccessible関数を作って、「任意の文字列キーを持つオブジェクト」であるならプロパティアクセスを許すようにしています。

また、以下のようにユーザー定義型ガードを使わない場合、returnName関数の引数のvalueHuman型と推論されず、プロパティアクセスができないためエラーとなります。
型推論をユーザー自身(開発者)で定義することで、TypeScriptに推論させることができるのです。

type Human = {
  type: "Human";
  name: string;
  age: number;
};

function returnName(value: unknown) {
  if (value == null) return false;

  if (
    value.type === "Human" &&
    typeof value.name === "string" &&
    typeof value.age === "number"
  ) {
    value.name; // ❌ ここでエラーになる
  }
}

最後に

今回は理解するのが難しかったユーザー定義型ガードに関して調べてみました。
公式や書籍では、使用場面がそれほどかかれておらず理解に時間がかかりました。
関数の引数によくわからない値を入れる際に使うことが多そうだと感じましたが、使用場面がそれほど多くないのかな?という印象が正直ありました。
ただ、実務で初見で見ると理解にかなり時間がかかりそうなので、事前にじっくり調べることができてよかったです。
また、記述量が多くなるので今回は省略しましたが、オブジェクトを引数にとる関数の場合、オブジェクトの中身が多くなったときにはどうするかなども調べてみると面白いかもしれません。

参考
プロを目指す人のためのTypeScript入門
【TS】今さら聞けないユーザ定義型ガード
TypeScript Deep Dive 日本語版

Discussion