先日、「TypeScriptのunion型はorではない」とする以下の記事が公開されました。
残念ながら筆者はこの主張に同意できないので、この記事では上記の記事に含まれる問題点を指摘しつつ、正しい結論を導きます。
この記事の目的はあくまで正しい情報を皆さまに届けることであり、上記の記事(以下では「元記事」と呼びます)の筆者を攻撃する意図は無いことをご理解ください。
では、元記事からコードを引用しながら何がまずいのかを見ていきましょう。
元記事の主張
元記事の最初のコードを引用します。
type A = {
a: string;
};
type B = {
b: number;
x: number;
};
type C = A | B;
const foo: C = {
a: "1",
x: 2,
};
C
はA | B
と定義されているので、union型の意味が「または (or)」であればこれは「A
またはB
」であるはずです。ここで、C
型の変数foo
に{ a: "1", x: 2 }
というオブジェクトを代入しても、コンパイルエラーは発生しません。
一方で、このオブジェクトをA
やB
に代入しようとするとコンパイルエラーとなります。
// エラー: Type '{ a: string; x: number; }' is not assignable to type 'A'.
// Object literal may only specify known properties, and 'x' does not exist in type 'A'.
const foo1: A = {
a: "1",
x: 2,
};
// エラー: Type '{ a: string; x: number; }' is not assignable to type 'B'.
// Object literal may only specify known properties, and 'a' does not exist in type 'B'.
const foo2: B = {
a: "1",
x: 2,
}
つまり、C
には「A
にもB
にも代入できないオブジェクト」を代入できることになります。これを以って、C
は「A
またはB
」ではないとするのが元記事の主張です。
TypeScriptは構造的部分型を採用している
では、元記事の主張のどこに問題があるのか見ていきます。まず、元記事では{ a: "1", x: 2 }
をA
にもB
にも代入できないとしていましたが、実は次のように別の変数を経由することでA
に代入できます。
type A = {
a: string;
};
type B = {
b: number;
x: number;
};
type C = A | B;
const obj = {
a: "1",
x: 2,
};
// エラーが発生しない!
const foo1: A = obj;
これは、TypeScriptの型システムが構造的部分型を採用しているからです。つまり、A
という型は「string
型のプロパティa
を持つ」という意味ですが、a
以外のプロパティを持つことは禁止していません。つまり、a
以外にb
やx
があろうが、A
型の条件を依然として満たしているのです。
よって、{ a: "1", x: 2 }
をA
型の変数に代入することは、型システム上まったく問題ありません。であるからこそ、このオブジェクトをC
に代入できたのです。
つまり、このオブジェクトをC
型の変数に代入できることは、C
がA
またはB
であることと矛盾しません。
余剰プロパティチェック
しかし、そうなると別の疑問が発生します。このオブジェクトを直接A
型の変数に入れようとすると次のようにエラーが発生してしまいます。
// エラー: Type '{ a: string; x: number; }' is not assignable to type 'A'.
// Object literal may only specify known properties, and 'x' does not exist in type 'A'.
const foo1: A = {
a: "1",
x: 2,
};
先ほどの説明からすれば、このオブジェクトをA
型の変数に入れるのは問題ないはずなのに、コンパイルエラーが発生していることになります。これが誤解の原因となっていると考えられます。
実はこのエラーは余剰プロパティチェック (excess property checks)と呼ばれるものであり、他のコンパイルエラーとは性質が異なります。というのも、このエラーは型システムに対する違反を検出しているものではないのです。言い換えれば、エラーを出さなくても型システム的には問題がないが、親切心でコンパイルエラーを出してくれているのです。
このコンパイルエラーは、余剰プロパティ、言い換えれば無駄なプロパティの存在を検出するものです。上のコードの場合、A
型にはa
というプロパティのみが定義されていますから、A
型の値を使う側のコードはa
プロパティにのみアクセスが許されます。実際にはx
というプロパティが存在していたとしても、それにアクセスする手段が(TypeScriptを欺かない限りは)存在しないのです。
つまり、A
型のオブジェクトにおいて、x
というプロパティはあっても使われません。決して使われないプロパティをわざわざ書くというのは、プログラマのミスである可能性が高いですね。これが、型システム上は問題ないのにわざわざコンパイルエラーを出す理由です。
余剰プロパティチェックが発生するタイミング
余剰プロパティチェックはオブジェクトリテラルに対して発生します。そして、そのオブジェクトリテラルに与えられる型があらかじめわかっている状況(この状況をcontextual typeがあると言います)で行われます。次のコードの場合、オブジェクトリテラルはA
型の変数foo1
に代入されることが分かっているため、余剰プロパティチェックの対象となります。
// エラー: Type '{ a: string; x: number; }' is not assignable to type 'A'.
// Object literal may only specify known properties, and 'x' does not exist in type 'A'.
const foo1: A = {
a: "1",
x: 2,
};
ところで、余剰プロパティチェックを回避する例を再掲します。
const obj = {
a: "1",
x: 2,
};
// エラーが発生しない!
const foo1: A = obj;
上の説明に照らせば、なぜここで余剰プロパティチェックが発生しないのか分かりますね。まず変数obj
への代入は、余剰プロパティチェックが発生しません。変数obj
の型が指定されていないからです。この場合、型推論によりobj
は{ a :string; x: number; }
型となります。
そして、foo1
にobj
を代入するところでも余剰プロパティチェックは発生しません。なぜなら、代入されるのがオブジェクトリテラルではないからです。構造的部分型のおかげで、obj
をA
型の変数に代入するのはまったく問題ありません(obj
が持つプロパティx
はこの時点で無用の長物となりますが)。
なお、foo1
にobj
を代入するときに余剰プロパティチェックを行えばいいのではないかという意見があるかもしれませんが、そうすると構造的部分型が形骸化してしまいますから、それは無理筋です。部分型という仕組み自体はとても有用なものです。
ユニオン型に対する余剰プロパティチェック
元記事から冒頭の例を再度引用します。
type A = {
a: string;
};
type B = {
b: number;
x: number;
};
type C = A | B;
const foo: C = {
a: "1",
x: 2,
};
変数foo
には型註釈があり、オブジェクトリテラルが代入されていますから、余剰プロパティチェックが行われる条件を満たしています。しかし、余剰プロパティチェックによるコンパイルエラーは発生しません。
実際のところ、このオブジェクトはA
型の条件を満たしますがB
型の条件を満たしていません(プロパティb
が無いため)。そして、A
型として見ればx
が余計ですから、頑張れば余剰プロパティチェックによるエラーを出せそうに思えます。
また、ユニオン型に対して全く余剰プロパティチェックが行われないわけでもありません。元記事の筆者が突き止めた通り、最初の例は余計なプロパティがx
だからこそ余剰プロパティチェックを回避しています。これは、x
というプロパティがB
に存在するからです。本来このオブジェクトはC
の構成要素のうちA
に当てはまるオブジェクトなので、A
と見なして余剰プロパティチェックを行なって欲しいところ、B
側に存在するx
は見過ごされます。
実際、次のようにA
にもB
にも存在しないy
を余計なプロパティとして追加すると余剰プロパティチェックによるコンパイルエラーが発生します。
type A = {
a: string;
};
type B = {
b: number;
x: number;
};
type C = A | B;
// エラー: Type '{ a: string; b: number; y: number; }' is not assignable to type 'C'.
// Object literal may only specify known properties, and 'y' does not exist in type 'C'.
const foo: C = {
a: "1",
b: 1,
y: 2, // error
};
では、なぜx
に対してはコンパイルエラーを発生させないのでしょうか。TypeScriptのバグでしょうか? 答えは否です。以下のissueに、Ryan Cavanaughさんによる説明があります(強調は筆者)。
This is the intended behavior. The provided value is a valid TYPE_A, and excess property checking (which should not be interpreted as a type system guarantee) allows properties to be present if they properly match some constituent in the target. Doing otherwise caused a ton of breaks in real working code.
つまり、これはintended behavior(意図通りの挙動)であり、ユニオン型に対する余剰プロパティチェックが行われる際は、ユニオン型の構成要素のいずれかに存在するプロパティは全て許可されるという仕様になっています。そうなっている理由は最後の一文で述べられています。さらに厳しいチェックを実装すると、後方互換性が激しく崩壊したからのようです(そもそも余剰プロパティチェックは当初から実装されていたものではなく、TypeScript 1.6で最初に実装されて以降徐々に追加・改善されてきたものです)。
余談
余談ですが、discriminated union type(リテラル型を用いてユニオン型の構成要素を区別するパターン)の場合はちゃんと余剰プロパティチェックを行うという仕様になっており、これはTypeScript 3.5のリリースノートに明記されています。しかし、なぜか現状はうまく行われない場合があり、バグとして扱われています。
- https://github.com/microsoft/TypeScript/issues/39050
- https://github.com/microsoft/TypeScript/issues/35890
まとめ
この記事では、TypeScript の共用体型(Union Types)は or ではないという記事に対する反論を述べ、TypeScriptのunion型は「or」であることを主張しました。特に、今回問題になっていた余剰プロパティチェックは、型システムに対する違反を検出するものではなく言わば追加の親切なチェックであり、union型の意味という型システム的な概念とは切り分けで考えるべきものです。