😲

引数の型をジェネリクスで定義した関数の中では引数の型の絞り込みはできない(TypeScript)

2024/09/19に公開

はじめに

TypeScript で融通が利く型を定義するのにジェネリクス(Generics)をよく利用すると思います。しかし、ジェネリクスで定義した引数を使った関数内で型を絞り込んでも引数の型が更新されず、期待通りに型が絞られないことがあります。この記事ではその例と、タプル型を用いて解決する方法を紹介します。

型引数が更新されない例

まず、問題となるコードを見てみましょう。

type XDirection = "left" | "right";
type YDirection = "top" | "bottom";

function f<T extends "x" | "y">(
  axis: T,
  direction: T extends "x" ? XDirection : YDirection
): void {
  if (axis === "x") {
    axis; // axis の型は "x" | "y"
    direction; // direction の型は XDirection | YDirection
  }
}

f("x", "left"); // OK
f("y", "top"); // OK

f("x", "top"); // Error: 型 '"top"' を型 'XDirection' に割り当てることはできません

TS Playground

この例では、関数 f の型引数 T"x" または "y" です。direction の型は、T"x" の場合は XDirection"y" の場合は YDirection となるはずです。関数の呼び出し時には想定通りに型が絞り込まれています。

しかし、関数内で axis === "x" として型を絞り込んでも、以降のブロックで T の型引数は更新されません。そのため、direction の型も XDirection | YDirection のままで、具体的な型に絞り込まれません。

問題点の詳細

  • 型引数 T が更新されない: 関数内での型の絞り込みは、型引数 T には影響を与えません
  • direction の型が曖昧: direction の型が XDirection | YDirection のままなので、XDirection に特有のメソッドやプロパティを安全に扱えません

なぜ型引数は更新されないのか

TypeScript の型システムでは、ジェネリクスの型引数は関数の呼び出し時に決定され、関数内での型の絞り込みによって変更されることはありません。つまり、型引数 T は関数全体で不変であり、条件分岐(Narrowing)によって変化しません。

これは Control flow analysis がジェネリクスに対応していないのが原因のようですが、具体的な説明は TypeScript の Docs にも明記されていないので見逃しやすいポイントです。

タプル型を使った解決策

この問題を解決するために、タプル型を使用する方法があります。

type XDirection = "left" | "right";
type YDirection = "top" | "bottom";

type Props = ["x", XDirection] | ["y", YDirection];

function f(...args: Props): void {
  const [axis, direction] = args;
  if (axis === "x") {
    axis; // axis の型は "x"
    direction; // direction の型は XDirection ("left" | "right")
  }
}

f("x", "left"); // OK
f("y", "top"); // OK

f("x", "top"); // Error: 型 '"top"' を型 'XDirection' に割り当てることはできません

TS Playground

解決策のポイント

  • タプル型 Props を定義: axisdirection の組み合わせをタプル型として定義します。
  • 関数の引数をタプル型に変更: 関数 f の引数を展開可能なタプル型 ...args: Props とします。
  • 型の絞り込みが有効になる: axis の値によって、direction の型も正しく絞り込まれます。

なぜこれで解決できるのか

タプル型を使用することで、axisdirection の組み合わせが明確になり、TypeScript の型推論が正しく働きます。具体的には、axis === "x" の条件分岐内では、axis の型が "x" に絞り込まれ、それに伴い direction の型も XDirection に絞り込まれます。

別の解決策

引数の型にこだわらなければ、オブジェクトを使って Discriminated Unions を使うこともできます。

type XDirection = "left" | "right";
type YDirection = "top" | "bottom";

type Props = { axis: "x", direction: XDirection } | { axis: "y", direction: YDirection }

function f({axis, direction}: Props): void {
  if (axis === 'x') {
    axis; // axis の型は "x"
    direction; // direction の型は XDirection ("left" | "right")
  }
}

f({axis: "x", direction: "left"}); // OK
f({axis: "y", direction: "top"}); // OK

f({axis: "x", direction: "top"}); // Error

TS Playground

まとめ

TypeScript におけるジェネリクスの型引数は、関数内での型の絞り込みによって自動的に更新されません。しかし、タプル型やオブジェクト型で Discriminated Unions を定義することで、型の絞り込みが期待通りに機能し、型安全なコードを書くことができます。

この記事が皆さんの TypeScript 開発に役立てば幸いです。

Special Thanks

参考

GitHubで編集を提案

Discussion