🐝

【TS】今さら聞けないユーザ定義型ガード

2021/05/12に公開4

はじめに

今回はTypescriptにおけるユーザ定義型ガードについてご紹介します。
独自に定義した型であるかどうかの判定を開発者自身が定義できる便利機能になっています。

やりたいこと

RPGの道具・武器の型定義を題材として扱います。
簡単に、以下のような型を定義したとします。

type Item = {
  name: string;
  use: () => void;
}

type Weapon = {
  atack: () => void;
} & Item;

Item型とWeapon型があり、Itemが道具でWeaponが武器として振る舞います。
どちらにも共通してnameuseというプロパティがあり、Weaponにだけatackというプロパティがあります。

実際にインスタンスとして「やくそう」と「こんぼう」、「バールのようなもの」を定義した場合は以下のようになります。
バールのようなものは、普段は道具ですが稀に武器(凶器)として扱われるためItemWeaponのユニオン型になっています。

const item1: Item = {
  name: 'やくそう',
  use: () => console.log("やくそうを使った!")
}

const item2: Weapon = {
  name: 'こんぼう',
  use: () => console.log('こんぼうを使った'),
  atack: () => console.log('こんぼうで殴った!10ダメージ!')
}

const item3: Item | Weapon = {
  name: 'バールのようなもの',
  use: () => console.log("バールのようなもののようだ"),
  //atack: () => console.log("バールのようなもので殴った!9999ダメージ!")
}

ここまでを前提として、下記のようにItem | Weapon型の引数を受けて「道具ならuse」を、「武器ならatack」を実行するuse関数を作りました。

const use = (item: Item | Weapon) => {
  // Itemならitem.use()を実行
  // Weaponならitem.atack()を実行
}

ここで問題となってくるのが「itemをどうやってItem型かWeapon型か判定するか」です。
いくつか思いつきそうな方法を試してみます。

①「プロパティの有無を確かめる?」

直感的に思いつくのがatackプロパティの有無です。
!!item?.atackの条件でif文を書いてみましたが、構文エラーになります。

const use = (item: Item | Weapon) => {
  if(!!item?.atack) {
    // ↑の文がエラーになる
    // Property 'atack' does not exist on type 'Item'.(2339)
  } 
}

どうやら解析上は、itemItem型である可能性が排除できないのでatackにアクセスできないようです。

②「typeofで判定すればいいのでは?」

ならばと条件分をtypeof item === 'Weapon'に変更してみました。
先ほどと同様にこちらも構文エラーが発生します。

const use = (item: Item | Weapon) => {
  if(typeof item === 'Weapon') {
    // ↑の文がエラーになる
    // This condition will always return 'false' since the types '"string" | "number" | "bigint" | "boolean" | "symbol" | "undefined" | "object" | "function"' and '"Weapon"' have no overlap.(2367)
  } 
}

typeofでは解析するインスタンスの型を厳密に判定するこはできない(ここでは"object"として判定されるだけです)ためこのようなエラーがでます。
そもそもItemだろうがWeaponだろうがobject型には違いないので、typeofの判定では意味を成しません。

③「any型にした上で①のやり方をすればいけるのでは?」

①の方法の変形で、引数の型をanyにしてみました。

const use2 = (item: any) => {
  // 構文エラーにならないが・・・
  if(!!item?.atack) {
    // これは本当にWeapon型のatack()なのか?
    item.atack();
  }
  else {
    // use()がないケースが出てくるのでは?
    item.use();
  }
}

構文エラーは出なくなりましたが、だいぶガバガバな関数になってしまいました。
anyを引数に取るため、ItemWeapon型以外のインスタンスを渡して実行することができてしまい、atackのプロパティがあってもWeapon型である保証がありません。

たとえばモンスターを表すMonster型を作ったとしても同名のプロパティを持っていそうです。
また、atackがなかったとしてもItem型であるとは限らないのでuseプロパティを持っていない可能性もあります。

ユーザ定義型ガードを使おう

こういった場合に便利なのがユーザ定義型ガードです。

https://typescript-jp.gitbook.io/deep-dive/type-system/typeguard#yznotype-guard

ユーザ定義型ガードを簡単に説明すると 「開発者自身が何をもってその型であるかを定義できる」 ということです。
Weapon型の例でいうと「atackプロパティがあればWeapon型とする」といった具合です。

ユーザ定義型ガードの書き方は簡単で、下記のように引数 is 型名を戻り値にします。

const isWeapon = (item: any): item is Weapon => {
  // Weapon型に強制キャストしてatackプロパティがあればWeapon型とする
  return !!(item as Weapon)?.atack
}

このisWeaponを使って条件分を組み立てることでuseの引数をanyにすることなく実装できます。

const use = (item: Item | Weapon) => {
  // エラーなし!
  if(isWeapon(item)) {
    // 以降itemはWeapon型として認識される
    item.atack();
  } 
  else {
    item.use();
  }
}

まとめ

今回はユーザ定義型ガードについてご紹介しました。
型判定を自由に記載できるため今回の例のように使い所は多いのかなと思います。

注意点としては自分で書いた型判定が誤っていると、意図しない挙動になってしまう可能性があります。
極端な例ですがisWeaponは常にtrueを返していても構文エラーにはならないので、その場合はItem型でもWeaponとして判定されてしまうようになります。
この辺りは自由に定義できるメリットの裏返しになっているので、十分に注意して扱いましょう。

Discussion

nishinanishina

とても分かりやすい記事ありがとうございました。

attackプロパティがWeaponにしか存在しないのであれば、ユーザー定義型ガードの戻り値!!(item as Weapon)?.attackinを利用して'attack' in itemとも書けそうだなと思いました。

type Item = {
  name: string;
  use: () => void;
}

type Weapon = {
  attack: () => void;
} & Item;

const isWeapon = (item: any): item is Weapon => 'attack' in item;

const use = (item: Item | Weapon) => {
  if(isWeapon(item)) {
    item.attack();
  }
  else {
    item.use();
  }
}

const item1: Item = {
  name: 'やくそう',
  use: () => console.log("やくそうを使った!")
}

const item2: Weapon = {
  name: 'こんぼう',
  use: () => console.log('こんぼうを使った'),
  attack: () => console.log('こんぼうで殴った!10ダメージ!')
}

use(item1); // やくそうを使った!
use(item2); // こんぼうで殴った!10ダメージ!

参考:

nekonikinekoniki

コメント、コード提供ありがとうございます!
inをあまり使ったことがなかったので勉強になります!

nap5nap5

isNullOrUndefinedのメソッドをユースケースとして定義し、チャレンジしてみました。

import { Chance } from "chance";
import { z } from "zod";

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
});

const UserDataSchema = UserSchema.deepPartial().nullish();

type UserData = z.infer<typeof UserDataSchema>;

const isNullOrUndefined = (data: unknown): data is null | undefined => {
  if (data === null || data === undefined) {
    return true;
  }
  return false;
};

const falsyValue = () => (Chance().bool() ? null : undefined);

const data: UserData = Chance().bool()
  ? {
      id: 37458,
      name: "cowboy",
    }
  : falsyValue();

(() => {
  if (isNullOrUndefined(data)) {
    console.log(data); // undefined or null
    return;
  }
  console.log(data.id); // neatValue
})();

demo

簡単ですが、以上です。

RyRyRyRy

Jestでテストを書く際に独自型の判定ケースを作成したかったので、こちらの記事の内容がわかりやすくて役に立ちました。ありがとうございます!