引数の型をジェネリクスで定義した関数の中では引数の型の絞り込みはできない(TypeScript)
はじめに
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' に割り当てることはできません
この例では、関数 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' に割り当てることはできません
解決策のポイント
-
タプル型
Props
を定義:axis
とdirection
の組み合わせをタプル型として定義します。 -
関数の引数をタプル型に変更: 関数 f の引数を展開可能なタプル型
...args: Props
とします。 -
型の絞り込みが有効になる:
axis
の値によって、direction
の型も正しく絞り込まれます。
なぜこれで解決できるのか
タプル型を使用することで、axis
と direction
の組み合わせが明確になり、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
まとめ
TypeScript におけるジェネリクスの型引数は、関数内での型の絞り込みによって自動的に更新されません。しかし、タプル型やオブジェクト型で Discriminated Unions を定義することで、型の絞り込みが期待通りに機能し、型安全なコードを書くことができます。
この記事が皆さんの TypeScript 開発に役立てば幸いです。
Discussion