30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

TypeScript の型ガードの注意点と解決法

TypeScript には型を推論する機能があり、条件分岐の際に自動的に型を絞り込んでくれる。この仕組みを型ガード(Type Guard)と呼ぶ。
ただし万能ではなく、自動的な絞り込みが機能しないケースもある。その場合、isを使って開発者が TypeScript に型を教えることで、解決できる。

この記事の内容は TypeScript のv3.9.5で動作確認している。

typeof による絞り込み

型を絞り込む方法はいくつかあるが、まずはtypeofを使った方法を紹介する。

const foo = (arg: number | string) => {
  const x = arg; // const x: string | number
  if (typeof arg === "number") {
    const y = arg; // const y: number
  } else {
    const z = arg; // const z: string
  }
};

上記のfooは、引数としてnumberstringを受け取る。
xの時点では、どちらなのかは判断できない。
だがif文でargtypeofをチェックしており、ifブロックのなかではargnumberであることが確定している。そして、TypeScript はそれを認識しており、ynumberだと自動的に推論してくれる。
そして、elseブロックのなかではargnumberではないことが確定しているため、型が絞り込まれ、stringであると推論される。

型の絞り込みは、if文以外の条件分岐でも機能する。

const foo = (arg: number | string) => {
  switch (typeof arg) {
    case "number": {
      const x = arg; // const x: number
      break;
    }
    case "string": {
      const y = arg; // const y: string
      break;
    }
    default: {
      const z = arg; // const z: never
    }
  }

  const a = typeof arg === "number" ? arg : null; // const a: number | null
};

switch文や三項演算子でも機能していることが分かる。

instanceof による絞り込み

typeofではなくinstanceofを使って絞り込むことも可能。

const foo = (arg: number | number[]) => {
  if (arg instanceof Array) {
    const x = arg; // const x: number[]
  } else {
    const y = arg; // const y: number
  }
};

argnumbernumber[]のいずかだが、Arrayのインスタンスである場合、numberである可能性が排除されるため、number[]に絞り込まれる。

in による絞り込み

inでプロパティの有無を調べることでも、絞り込みが行われる。

type A = {
  a: number
}
type B = {
  b: number
}

const foo = (arg: A | B) => {
  if ('a' in arg) {
    const x = arg; // const x: A
  } else {
    const y = arg; // const y: B
  }
};

argaというプロパティを持っている場合、argBである可能性が消えるため、Aに絞り込まれる。

型ガードはインラインで書かないと機能しない

型ガードは便利な機能だが、落とし穴も多い。

まず、条件式をインラインで書かないといけない。
例えば、以下のコードでは型ガードが機能する。

const foo = (arg: number | string) => {
  if (typeof arg === "number") {
    const x = arg; // const x: number
  }
};

しかし以下のコードでは、barにおいてもbazにおいても、型ガードが機能していない。
先程のfooの例と処理の内容は全く同じなのだが、機能しない。

const bar = (arg: number | string) => {
  const isNumber = (arg: number | string) => typeof arg === "number";

  if (isNumber(arg)) {
    const x = arg; // const x: string | number
  }
};

const baz = (arg: number | string) => {
  const isNumber = typeof arg === "number";

  if (isNumber) {
    const x = arg; // const x: string | number
  }
};

if文以外も同じで、インラインで書いたa以外では型ガードが機能していない。

const foo = (arg: number | string) => {
  const isNumber = (arg: number | string) => typeof arg === "number";

  const boolean = typeof arg === "number"

  const a = typeof arg === "number" ? arg : null; // const a: number | null
  const b = isNumber(arg) ? arg : null; // const b: string | number | null
  const c = boolean ? arg : null; // const c: string | number | null
};

ユーザー定義型ガード

isを使うことで、条件式を切り出すことが可能になる。
isは、開発者が TypeScript に対して型を教えるための機能で、「ユーザー定義型ガード」と呼ばれる。

関数の返り値を引数 is Tとアノテートすると、「その関数がtrueを返す場合は引数はTであり、falseを返す場合はTではない」と TypeScript に指示することになる。

以下のisNumberは、返り値をarg is numberとアノテートしているため、trueを返した場合はargnumberであると見做される。
そのため、ifブロックのなかではargnumberに絞り込まれている。

const bar = (arg: number | string) => {
  const isNumber = (arg: number | string): arg is number =>
    typeof arg === "number";

  if (isNumber(arg)) {
    const x = arg; // const x: number
  }
};

isを使っている関数で真偽値以外を返すと、エラーになる。

const bar = (arg: number | string) => {
  // Type '1' is not assignable to type 'boolean'.ts(2322)
  const isNumber = (arg: number | string): arg is number => 1;
};

この機能を使うことで、型ガードを関数として切り出すことが可能になるし、複雑な型ガードを実装することも可能になる。
しかしisもまた万能ではなく、注意点がいくつかある。

まず、ユーザー定義型ガードは条件式のなかで使う必要がある。
以下のコードでは、isNumberの結果をbooleanに代入しそれを条件式で使っているが、そうすると型ガードが機能しなくなってしまう。

const bar = (arg: number | string) => {
  const isNumber = (arg: number | string): arg is number =>
    typeof arg === "number";

  const boolean = isNumber(arg);

  if (boolean) {
    const x = arg; // const x: string | number
  }
};

また、isを使ったアノテートが実装と矛盾していたとしても、TypeScript はエラーを出さないので、開発者が注意を払わなければならない。

以下のisNumberは、arg is numberとするべき箇所を、arg is stringと書いてしまっている。
だがエラーにはならず、TypeScript はその指示を愚直に信じてしまう。
その結果、xは実際にはnumberなのだが、TypeScript はそれをstringだと判断してしまう。

const bar = (arg: number | string) => {
  const isNumber = (arg: number | string): arg is string =>
    typeof arg === "number";

  if (isNumber(arg)) {
    const x = arg; // const x: string
  }
};

オブジェクト型の絞り込み

オブジェクトに対して型の絞り込みを行う場合、気をつけなければならない点がさらに増える。

対比のためにまず、プリミティブの場合の挙動を見てみる。

以下のコードでは、argの型をnumberに絞り込んだ後、関数を受け取る関数であるfooを実行している。
fooに渡す関数のなかでargを使っているが、TypeScript はこれをnumberだと認識してくれている。

const foo = (callback: () => void) => {
  callback();
};

const bar = (arg: number | string) => {
  const a = arg; // const a: string | number

  if (typeof arg === "number") {
    const b = arg; // const b: number
    foo(() => {
      const c = arg; // const c: number
    });
  }
};

ifブロックのなかではargが絞り込まれているのだから、ごく自然な挙動だと思う。

しかし、argの型をオブジェクトにすると、直感に反する挙動になる。

以下のコードでは、argxというプロパティを持つオブジェクトであり、xの型はnumber | stringである。
そして、typeofを使った絞り込みを行っている。

type SomeObject = {
  x: number | string;
};

const foo = (callback: () => void) => {
  callback();
};

const bar = (arg: SomeObject) => {
  const a = arg.x; // const a: string | number

  if (typeof arg.x === "number") {
    const b = arg.x; // const b: number
    foo(() => {
      const c = arg.x; // const c: string | number
      const d = b; // const d: number
    });
    const e = arg.x; // const e: number
  }
};

bを見ると、絞り込みが機能していることが分かる。
enumberになっているため、ifブロックのなかではarg.xnumberと推論されているはずである。

しかしcを見ると、なぜか絞り込みが行われず、string | numberになってしまっている。

実はオブジェクトの場合、新しい関数のスコープが作られると、そのなかでは型ガードによる推論結果が失われてしまう。

type SomeObject = {
  x: number | string;
};

const bar = (arg: SomeObject) => {
  if (typeof arg.x === "number") {
    const x = arg.x; // const x: number
    () => {
      const y = arg.x; // const y: string | number
    };
    function baz() {
      const z = arg.x; // const z: string | number
    }
  }
};

絞り込んだ結果を保持したい場合は、先程のdのように、推論された値を変数(b)に入れておきそれを代入する必要がある。

あるいは、ユーザー定義型ガードを使うことでも解決できる。

type SomeObject = {
  x: number | string;
};

const foo = (callback: () => void) => {
  callback();
};

const xIsNumber = (arg: SomeObject): arg is { x: number } => {
  return typeof arg.x === "number";
};

const bar = (arg: SomeObject) => {
  const a = arg.x; // const a: string | number

  if (xIsNumber(arg)) {
    foo(() => {
      const c = arg.x; // const c: number
    });
  }
};

関数を入れ子にしても、問題なく機能する。

type SomeObject = {
  x: number | string;
};

const xIsNumber = (arg: SomeObject): arg is { x: number } => {
  return typeof arg.x === "number";
};

const bar = (arg: SomeObject) => {
  if (xIsNumber(arg)) {
    () => {
      const x = arg.x; // const x: number
      () => {
        const y = arg.x; // const y: number
      };
    };
  }
};