30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

TypeScript の共用体型(Union Types)は or ではない(追記あり)

〜2020/7/8 追記〜

本記事に対して指摘を頂いた。

qiita.com

「余剰プロパティチェックの存在やそのルールによってorではないかのように見える(ことがある)というだけで、型システム的にはorである」と、私は解釈した。

特に「余剰プロパティチェックは、型システムに対する違反を検出するものではなく言わば追加の親切なチェック」という点が重要であり、確かにそこを無意識のうちに混同していた。

分かりやすく説明して頂いているので上記の記事を読めば十分かもしれないが、勉強も兼ねて、自分でも整理しておく。

共用体型は or である

TypeScript では、オブジェクトに余計なプロパティがあっても問題にならない。
以下の例だと、string型のaというプロパティが存在していればAの条件を満たしていることになり、余計なプロパティが存在してもエラーにはならない。

type A = {
  a: string;
};

const aAndS = {
  a: "a",
  s: 1,
};

const aAndT = {
  a: "a",
  t: true,
};

const sAndT = {
  s: 1,
  t: true,
};

let expectA: A;

expectA = aAndS; // ok
expectA = aAndT; // ok
expectA = sAndT; // プロパティ a が存在しないため、エラーになる

そのため、以下のaAndXCに割り当てることができる。

type A = {
  a: string;
};

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

type C = A | B;

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

const expectA: A = aAndX; // ok
const expectC: C = aAndX; // ok

aAndXAの条件を満たしているため、「AもしくはBである」というCにも、割り当てることができる。
つまり、A | BA or Bであり、「共用体型はor(日本語の「もしくは」)である」と言ってよい。

「共用体型はorである」という話についてはここで終わりなのだが、「余剰プロパティチェック」の存在によって、話はもう少し複雑になっていく。

余剰プロパティチェック

先程、「余計なプロパティがあっても問題にならない」と書いたが、これには例外がある。
オブジェクトリテラルを型に割り当てようとしたときは、余計なプロパティがあるとエラーになってしまう。

type A = {
  a: string;
};

const aAndS = {
  a: "a",
  s: 1,
};

let expectA: A;

expectA = aAndS; // ok

expectA = {
  a: "a",
  s: 1, // 余計なプロパティ s があるので、エラーになる
};

しかし、冒頭にも書いたが、これは型システムに対するエラーではないのである。
既に書いたように、TypeScript では余計なプロパティがあっても許容するため、型システム上は問題ない。
ただ、オブジェクトリテラルを割り当てる際に余計なプロパティがあれば、それは開発者のミスである可能性が高い。そのため、TypeScript はエラーを出すのである。
この仕組みを「余剰プロパティチェック(excess property checks)」という。
そしてこれは、開発者の利便性のためにチェックしてくれているに過ぎず、「共用体型はorであるか」という型システムの話とは、また異なるレイヤーの話である。

しかし、余剰プロパティチェックの対象が共用体型の場合は、これまでの説明とは矛盾する挙動になる。私は、これが原因で勘違いをしてしまっていた。
CであるexpectC2に対してオブジェクトリテラルを割り当て、そして余計なプロパティであるxがあるはずなのに、エラーにならない。

type A = {
  a: string;
};

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

type C = A | B;

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

const expectA: A = aAndX; // ok

const expectC1: C = aAndX; // ok

// ok
// オブジェクトリテラルを割り当てているのに、エラーにならない
const expectC2: C = {
  a: "1",
  x: 2,
};

xが、Cの構成要素であるBに存在するため、このような挙動になる。もう一方の型に存在するプロパティなら、余剰プロパティチェックの対象にはならないのである。
Bに存在しないプロパティだと、エラーになる。

type A = {
  a: string;
};

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

type C = A | B;

const aAndY = {
  a: "1",
  y: 2,
};

const expectA: A = aAndY; // ok

const expectC1: C = aAndY; // ok

const expectC2: C = {
  a: "1",
  y: 2, // 余計なプロパティ y があるので、エラーになる
};

冒頭の記事によると、互換性の問題でこのような挙動になっているとのこと。

〜追記終わり〜

この記事の内容は、TypeScript のv3.9.5で動作確認している。

TypeScript をよく理解している人にとっては常識なのかもしれないが、共用体型はorではない。つまり、A | Bは、A or Bではない。
当初これを理解していなかったため、なぜ以下のコードがエラーにならないのか分からず混乱した。

type A = {
  a: string;
};

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

type C = A | B;

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

{a: "1", x: 2}Aではないし、Bでもない。
実際、ABに割り当てようとすると、エラーになる。にも関わらず、A | BであるCには割り当てることができるのである。

type A = {
  a: string;
};

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

type C = A | B;

const bar: A = {
  a: "1",
  x: 2, // error
};

const baz: B = {
  a: "1", // error
  x: 2,
};

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

このことから、A | Bは単にA or Bを意味するのではない、ということが分かる。

ABもプリミティブ型であるなら、共用体型をorと認識しても問題ない。

以下のCは「string、もしくはnumber」を意味しており、それ以外の型を割り当てようとするとエラーになる。

type A = string;

type B = number;

type C = A | B;

// ok
const foo: C = "a";

// ok
const bar: C = 1;

// error
const baz: C = true;

// error
const quz: C = {};

問題は、ABがオブジェクト型のときである。

ABもオブジェクトのとき、A | Bはまず、AB、どちらかの条件を満たしている必要がある。
これは分かりやすいと思う。

type A = {
  a: string;
};

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

type C = A | B;

// A を満たしている
const foo: C = {
  a: "1",
};

// B を満たしている
const bar: C = {
  b: 1,
  x: 2,
};

そしてその条件さえ満たしていれば、もう一方の型のプロパティを自由に追加できるのである。

type A = {
  a: string;
};

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

type C = A | B;

// A を満たしたうえで、b を追加している
const foo: C = {
  a: "1",
  b: 1,
};

// A を満たしたうえで、x を追加している
const bar: C = {
  a: "1",
  x: 2,
};

AB、両方のプロパティを全て持たせても問題ない。

type A = {
  a: string;
};

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

type C = A | B;

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

どちらにも存在しないプロパティを追加してしまうと、エラーになる。

type A = {
  a: string;
};

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

type C = A | B;

const foo: C = {
  a: "1",
  b: 1,
  y: 2, // error
};

共用体型の要素の数を増やしても、この法則は変わらない。

type A = {
  a: number;
  b: number;
};

type T = {
  t: number;
  u: number;
};

type X = {
  x: number;
  y: number;
};

type Z = A | T | X;

// ok
const foo: Z = {
  a: 1,
  b: 1,
  t: 1,
  y: 1,
};

// いずれの型も満たしていないので、エラーになる
const bar: Z = {
  a: 1,
  t: 1,
  y: 1,
};

// いずれの型にも存在しないプロパティ q を含めているので、エラーになる
const baz: Z = {
  a: 1,
  b: 1,
  q: 1,
};

// ok
const qux: Z = {
  a: 1,
  b: 1,
  t: 1,
  u: 1,
  x: 1,
  y: 1,
};

TypeScript の条件型(Conditional Type)と infer キーワード

TypeScript には条件型(Conditional Type)という機能があり、これを使うと型定義に条件分岐を持ち込むことができる。

この記事の内容は TypeScript のv3.9.5で動作確認している。

条件型の基本

T extends U ? A : Bが、条件型の構文。
TUに割り当て可能なときはA、そうでないときはBを意味する。

そしてtype Example<T> = T extends U ? A : B;のように、ジェネリック型と組み合わせて使う。

ジェネリック型については、以前記事を書いた。

numb86-tech.hatenablog.com

条件型とジェネリック型の組み合わせ例を示す。
以下のIsStringは、渡された型引数Tstringに割り当て可能なときはtrueを、不可能なときはfalseを返す。

type IsString<T> = T extends string ? true : false;

type A = IsString<string>; // type A = true
type B = IsString<"a">; // type B = true
type C = IsString<number>; // type C = false

分配法則

条件型は、分配法則に従う。
そのため、T extends U ? A : BTが共用体型のとき、Tのそれぞれの要素に対して条件型が展開される。

T1 | T2 extends U ? A : B
// ↓
(T1 extends U ? A : B) | (T2 extends U ? A : B)

この性質を使うことで、共用体型の中から特定の条件を満たした要素のみを取り出す、ということが可能になる。

例えば以下のExtractStringは、渡された値の中から、string及びそのサブタイプを取り出す。

type ExtractString<T> = T extends string ? T : never;

type A = ExtractString<1 | "a" | "b">; // type A = "a" | "b"
type B = ExtractString<1 | 2>; // type B = never

ExtractString1 | "a" | "b"が渡された場合、以下のように計算が進む。

ExtractString<1 | "a" | "b">
// ↓
1 | "a" | "b" extends string ? T : never
// ↓
(1  extends string ? T : never) | ("a" extends string ? T : never) | ("b" extends string ? T : never)
// ↓
never | "a" | "b"

neverの性質上、T | neverTになるので、最終結果は"a" | "b"になる。

neverの性質やそれを使ったテクニックについては、下記を参照。

numb86-tech.hatenablog.com

TypeScript の組み込みの条件型でも、このテクニックがよく使われている。

/**
 * Exclude from T those types that are assignable to U
 */
type Exclude<T, U> = T extends U ? never : T;

/**
 * Extract from T those types that are assignable to U
 */
type Extract<T, U> = T extends U ? T : never;

/**
 * Exclude null and undefined from T
 */
type NonNullable<T> = T extends null | undefined ? never : T;

infer キーワードによるキャプチャ

inferキーワードを使うと、条件にマッチした際にその一部分をキャプチャして利用することが可能になる。

T extends U ? A : Bの、Uの部分でinferを使う。
そうすると、条件にマッチしたときにその型をキャプチャし、それをAの部分で使えるようになる。

type ResolvedType<T> = T extends Promise<infer X> ? X : never;

type A = ResolvedType<Promise<1>>; // type A = 1
type B = ResolvedType<Promise<string>>; // type B = string
type C = ResolvedType<1>; // type C = never

inferは、ジェネリック型のようなものだと考えることができる。上記のResolvedTypeの定義で使っているXは、定義した時点では型が決まっていない。

ResolvedTypeに型引数が渡されると TypeScript は、その型をTにバインドする。そしてその型に応じて、Xが何であるかを推論するのである。
どのような型もXにバインドできない場合は、条件にマッチしなかったと判定され、neverが返される。
Xに具体的な型をバインドできた場合は条件にマッチしたと判定され、Xが返される。

inferも、組み込みの条件型で使われている。
以下は、関数の返り値の型を返すReturnTypeの例。

/**
 * Obtain the return type of a function type
 */
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;