LoginSignup
250
106

More than 3 years have passed since last update.

TypeScriptのunion型はorです 〜union型、構造的部分型、余剰プロパティチェックの話〜

Last updated at Posted at 2020-07-07

先日、「TypeScriptのunion型はorではない」とする以下の記事が公開されました。

残念ながら筆者はこの主張に同意できないので、この記事では上記の記事に含まれる問題点を指摘しつつ、正しい結論を導きます。

この記事の目的はあくまで正しい情報を皆さまに届けることであり、上記の記事(以下では「元記事」と呼びます)の筆者を攻撃する意図は無いことをご理解ください。

では、元記事からコードを引用しながら何がまずいのかを見ていきましょう。

元記事の主張

元記事の最初のコードを引用します。

元記事から引用
type A = {
  a: string;
};

type B = {
  b: number;
  x: number;
};

type C = A | B;

const foo: C = {
  a: "1",
  x: 2,
};

CA | Bと定義されているので、union型の意味が「または (or)」であればこれは「AまたはB」であるはずです。ここで、C型の変数foo{ a: "1", x: 2 }というオブジェクトを代入しても、コンパイルエラーは発生しません。

一方で、このオブジェクトをABに代入しようとするとコンパイルエラーとなります。

// エラー: 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以外にbxがあろうが、A型の条件を依然として満たしているのです。

よって、{ a: "1", x: 2 }A型の変数に代入することは、型システム上まったく問題ありません。であるからこそ、このオブジェクトをCに代入できたのです。

つまり、このオブジェクトをC型の変数に代入できることは、CAまたは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; }型となります。

そして、foo1objを代入するところでも余剰プロパティチェックは発生しません。なぜなら、代入されるのがオブジェクトリテラルではないからです。構造的部分型のおかげで、objA型の変数に代入するのはまったく問題ありません(objが持つプロパティxはこの時点で無用の長物となりますが)。

なお、foo1objを代入するときに余剰プロパティチェックを行えばいいのではないかという意見があるかもしれませんが、そうすると構造的部分型が形骸化してしまいますから、それは無理筋です。部分型という仕組み自体はとても有用なものです。

ユニオン型に対する余剰プロパティチェック

元記事から冒頭の例を再度引用します。

元記事から引用
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のリリースノートに明記されています。しかし、なぜか現状はうまく行われない場合があり、バグとして扱われています。

まとめ

この記事では、TypeScript の共用体型(Union Types)は or ではないという記事に対する反論を述べ、TypeScriptのunion型は「or」であることを主張しました。特に、今回問題になっていた余剰プロパティチェックは、型システムに対する違反を検出するものではなく言わば追加の親切なチェックであり、union型の意味という型システム的な概念とは切り分けで考えるべきものです。

250
106
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
250
106