一. 共変性、反変性、不変性
共変性と反変性はコンピューターサイエンス専門用語であり、共変性は元のタイプよりも派生度が高い(具体的な)タイプを使用できることを指し、反変性は元のタイプよりも派生度が低い(あまり具体的でない)タイプを使用できることを指します。また、不変性というものもあり、固定タイプとも呼ばれ、最初に定義されたタイプを使用します。固定タイプは共変性でも反変性でもありません。
TypeScriptでは、関数は共変性と反変性をサポートし、型の割り当てと使用において柔軟性を提供します。関数のパラメーターには反変性があり、戻り値には共変性があります。反変性とは、親タイプが子タイプに割り当てられることを指します。TypeScriptで関数の型を宣言する際、関数パラメーターの型は子タイプですが、実際に割り当てることができる関数パラメーターは親タイプです。一方、共変性は、戻り値の子タイプを親タイプに割り当てることができることを指します。
過去の開発において、次のような問題に直面しました。以下は、typeによってApiMapで関連するAPIをマッピングするシナリオです。このコードは非常に正常かつ合理的に見えますが、次のように書くとコンパイラがTypeScriptのエラーを報告します。しかし、api(type === SourceType.Use_A ? 1 : 'str')
とコードを書くと、型推論に問題が生じます。
二. 構造化
最初に「構造化」について紹介します。構造化タイプは、TypeScriptの型システムにおける重要な特徴の一つであり、代表的な例がインターフェース(interface)です。インターフェースは、オブジェクトの形状を記述するために使用され、オブジェクトの具体的な実装や名称には関心を持ちません。オブジェクトの構造がインターフェースの定義に合致していれば、それは互換性があると見なされます。例えば:
interface Name {
name: string;
}
interface Age {
age: number
}
interface Son {
name: string;
age: number;
}
子クラスは親クラスよりも具体的(属性が多い)です。Son は Name と Age の共有サブタイプであり、Name と Age には関連性がありません。
三. 型変換
型変換は、型間の変換関係を指します。TypeScript では、協変(covariant)、逆変(contravariant)、双方向協変(bivariant)、および不変(invariant)に分かれます。これらの概念を理解することで、型間の互換性と制約をより良く管理できます。
簡単な親子型を記述してみましょう:
Dog は Animal を継承しており、Animal よりも多くのメソッドを持っています。したがって、Animal は親型(親タイプ)であり、Dog はその子型(子タイプ)です。注意が必要なのは、子型の属性は親型よりも多く、より具体的であることです。
interface Animal {
age: number
}
interface Dog extends Animal {
bark(): void
}
ユニオン型において、親子型の関係に注意が必要です。これは確かに「直感に反する」ことがあります。例えば、'a' | 'b' | 'c' は一見、'a' | 'b' よりも属性が多く見えますが、実際には 'a' | 'b' | 'c' は 'a' | 'b' の子型ではなく、逆に 'a' | 'b' | 'c' は 'a' | 'b' の親型です。これは前者がより広い範囲を含み、後者がより具体的であるためです。
type Parent = "a" | "b" | "c";
type Child = "a" | "b";
let parent: Parent;
let child: Child;
parent = child // 互換可能
child = parent // 非互換。parentは「c」の可能性がありますが、childの型には「c」が含まれていません。
- 型システムにおいて、属性が多い型はサブタイプです。
- 集合論では、属性が少ない集合は部分集合です。したがって、ユニオン型において属性が少ないものはサブタイプです。
- 親タイプはサブタイプよりも広く、包含する範囲が広いです。一方、サブタイプは親タイプよりも具体的です。
- サブタイプは必ず親タイプに代入することができます。
extends
前面、親子型について既に理解しました。親子型を語るときに、TypeScript(TS)でよく使用される extends キーワードを思い出すことができます。例えば、TSの組み込み型では、次のようなコードをよく見かけます:
type NonNullable<T> = T extends null | undefined ? never : T;
type Diff<T, U> = T extends U ? never : T;
type Filter<T, U> = T extends U ? T : never;
extends は条件型キーワードです。次のコードは、「もし T が U のサブタイプであれば、結果は X となり、そうでなければ結果は Y となる」と理解できます。
T extends U ? X : Y
親タイプとサブタイプを理解すれば、条件型の理解は非常に簡単です。
T がユニオン型の場合、それは分布条件型(Distributive conditional types)と呼ばれます。これは数学の因数分解に似ています: (a + b) * c = ac + bc
つまり、T が "A" | "B"
の場合、それは ("A" extends U ? X : Y) | ("B" extends U ? X : Y)
に分解されます。
type Diff<T, U> = T extends U ? never : T;
const demo: Diff<"a" | "b" | "d", "d" | "f">;
// result: "a" | "b"
-
"a"
は"d" | "f"
のサブセットではないので、"a"
を取ります。 -
"b"
は"d" | "f"
のサブセットではないので、"b"
を取ります。 -
"d"
は"d" | "f"
のサブセットなので、never
を取ります。 - 最後に結果として
"a" | "b"
となります。
共変
共変とは、上述で何度も言及した「サブタイプは親タイプに代入できる」という概念です。関数の戻り値は共変です。共変はダックタイピングでも理解できます。つまり、オブジェクトが見た目や振る舞いがアヒルのようであれば、それはアヒルと見なされます。
次のコードを見てみましょう:
class Animal {
walk() {}
}
class Dog extends Animal {
bark() {}
}
let dogFunc: () => Dog = () => new Animal();
let animalFunc: () => Animal = () => new Dog();
dogFunc
関数を定義する際に、戻り値の型が Dog
であるとします。もし dogFunc
を実装する際に、戻り値の型が Animal
である場合、戻り値の親型は戻り値の子型に代入することはできないため、エラーになります ❌。
一方、animalFunc
関数を定義する際に、戻り値の型が Animal
であるとします。もし animalFunc
を実装する際に、戻り値の型が Dog
である場合、戻り値の子型は戻り値の親型に代入することができるため、正しいです ✅。
逆変
文字通り、逆変とはタイプの変化ルールを逆にすることを意味します。つまり、親タイプが子タイプに代入できるようになります。TypeScript では、関数の引数が逆変の特性を持っています。
interface Animal {
walk(): void;
}
interface Dog extends Animal {
bark(): void;
}
let animalFunc: (animal: Animal) => void;
animalFunc = (animal: Animal) => {
animal.walk();
}
let dogFunc = (dog: Dog) => void;
dogFunc = (dog: Dog) => {
dog.bark();
}
dogFunc = animalFunc;
animalFunc = dogFuncl
上記の関数 animalFunc は Animal 型の引数を受け取り、dogFunc は Dog 型の引数を受け取ります。animalFunc を dogFunc に代入しようとすると、または dogFunc を animalFunc に代入しようとすると、コンパイラはエラーを報告しますか?
animalFunc
を dogFunc
に代入してもコンパイラはエラーを報告しませんが、dogFunc
を animalFunc
に代入すると、コンパイラがエラーを報告します。animalFunc
は Animal
型の引数を受け取り、dogFunc
は Dog
型の引数を受け取ります。そして、Dog
型は Animal
型のサブタイプです。
つまり、親タイプの引数を持つ関数は子タイプの引数を持つ関数に代入できますが、子タイプの引数を持つ関数は親タイプの引数を持つ関数に代入できません。この現象が逆変です。
ここまで読んで、共変と逆変について理解が深まったと思います。それでは、冒頭の問題を次の二点で分析してみましょう。
-
まず、TypeScript はコードを静的に解析します。つまり、実行時のコンテキストを取得することができず、
api
の型とtype
がどのように関連しているかを知ることができません。api
関数の呼び出し時に、関数がユニオン型のどの型に推論されるかを判断できないため、手動での型アサーションが行われない限り、関数の引数位置での型は逆変し、関数の戻り値位置での型は共変します。 -
次に、
(arg: number) => number | (arg: string) => string
の変化結果は実際には(arg: never) => string | number
です。この場合、arg
の型の安全性を保証するために、arg
は逆変(親タイプから子タイプへの変化)し、number
とstring
の共通のサブタイプになります。 -
TypeScript のこの PR には次のように書かれています:逆変位置における同一型変数の複数の候補は交差型として推論されます。
https://jkchao.github.io/typescript-book-chinese/tips/covarianceAndContravariance.html#面白い問題
したがって、引数の位置で得られる結果は type T = string & number; // never です。 -
戻り値の位置では共変(子タイプから親タイプへの変化)が適用され、得られる親タイプは string | number です。
ここまででこの問題の分析が完了しました。解決方法は簡単で、手動で型アサーションを行うことができます。
export const handleClick1 = (type: SourceType) => {
const [api] = ApiMap[type];
if (type === SourceType.Use_A) {
(api as Api_A)(1);
} else {
(api as Api_B)("str");
}
};
export const handleClick2 = (type: SourceType) => {
const [api] = ApiMap[type];
const assertApi_A = () => api as Api_A;
const assertApi_B = () => api as Api_B;
if (type === SourceType.Use_A) {
assertApi_A()(1);
} else {
assertApi_B()("str");
}
};
四. まとめ
共変はタイプの狭化を意味し、逆変はタイプの拡張を意味します。
- 関数の引数は逆変です:親タイプ → 子タイプ
- 関数の戻り値は共変です:子タイプ → 親タイプ
単純なデータ型や構造(オブジェクトやクラス)型については、型を最も安全な型に狭める必要があります。関数の戻り値についても同様です。
一方で、関数の引数については、引数の型を最も安全な型に拡張する必要があります(例えば、少なくとも同じ基底クラスを持つべきです)。
要するに、関数がコールバックパラメーターとして渡される場合、このコールバック関数の戻り値の型が共変であり、そのコールバック関数の引数の型が逆変であることを確認する必要があります。これにより、渡されるコールバック関数が指定された型またはそのサブタイプであることが保証されます。
関数は広い範囲を持つ傾向があります。引数が犬であれば犬を受け取り、引数が動物であれば犬も受け取ることができます。このため、これが許容されますが、逆に、犬が他の動物を受け取ることはできません。
タイプの安全性の観点から階層関係を理解することで、型変換の方向が異なるものの、目的は同じであることがより良く理解できます。