yn2011's blog

技術メモ

TypeScript の共用体型は or ではないのかについて考えた

TypeScript の共用体型(Union Types)は or ではない を読み、確かによく分からん挙動だなーと思って色々調べたり考えたりしたことを書いておく。

最初に書いておくと、結論はよく分かってない。

まず、共用体型はタグ無し共用体型とタグ付き共用体型(Discriminated Unions)の2つに分けることができる。ので分けて考える。タグ付き共用体型が何を指すのかは後述する。

タグ無し共用体型

最初に、タグ無し共用体型について考える。

形状A, B に対して、A | BA & B で定義される型の性質

そもそもの性質の整理。何かベン図を使った説明をすることが多いみたいだけど、形状*1を表す型の場合はベン図ではうまく説明できない気がしている。

  • A | B は 少なくとも、A, B のいずれかの型を満たすようなプロパティを持つ(Aを満たすなら部分的にBのプロパティを持つことは許容される)

  • A & B は A, B のプロパティを全て持つ形状(A, B を満たしながら他のプロパティを持つことは許容されない)

ここで、A | BPartial(A & B)、つまり A, B のプロパティを全て持つが、省略も可能な形状と同値なのか?と思ったが、それは違う。A | B は、

  • A,B のプロパティを部分的に持ち、A, B のいずれも満たさない場合は不可
  • プロパティを持たない形状 {} は代入不可
type A = {
  a: string;
};

type B = {
  b: number;
};

type C = A | B;

const ab : C = {} // error 

A | B = A ∪ Bという理解は正しいのか

ベン図で理解しようとすると、A | B = A ∪ B 、つまり、AかBかAとB両方を満たす集合なんじゃないかと考えたくなる。

集合A を形状Aに割り当て可能な型とみるか、形状Aが持つプロパティの集合と見るかで違ってきそう。

前者で考えると、A を満たしながら B のプロパティを一部持つ形状は、A に割当不可能だし、A, B, A&B のどれにも割当不可能なので、A ∪ B に含まれないということになってしまう。「共用体型が or でない」ように思える。

後者で考えると、型を満たすかは関係なくて、単にプロパティの集合なので A ∪ B になる。すると共用体型が or であるように思える。

結局 A | B とは何なのか

少なくとも、A, B のいずれかの型を満たすプロパティを持つ形状としか言えないような。A, B のいずれかの型を実際に満たすわけでなくて、(余計なプロパティがなければ)少なくとも A, B のいずれかを満たすようなプロパティを持つ形状というだけなことが大事なのかも。

同じことを、単一の形状として、オブジェクトリテラル表記で形状の型として定義することはできない気がしている。

ちなみに type-fest というライブラリに、RequireAtLeastOne という型定義があり、見た感じ形状をA | Bすることで得られる性質を利用しているみたいだった。この型ユーティリティは1つの形状から特定のプロパティの集合を選択していずれかを必須にできるので、もっと汎用性が高い感じ。

例えば、RequireAtLeastOne<A, "a" | "b"> すると a, b のどちらかを必須にできる。

型定義の理解の仕方はこの記事 が役に立った。

タグ付き共用体型

次にタグ付き共用体型について考える。そもそもタグ付き共用体型とは何かというと、こういうやつ。

type A = {
  type: 'a'
  a: string
};

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

type C = A | B;

タグは、同じプロパティで定義され、値は共用体型の中で一意である必要がある。

この場合は、単純で、A | B は必ず A, B のいずれかになる。両方を満たしたり、片方を満たしながら片方のプロパティを持つことはできない。ベン図でいうと、A ∪ B から A ∩ Bを除いた集合で、形状以外の型の共用体型はこうなるはず。

なので、上の例でいうとこういうのはエラーになる。A を満たしているが、他の型のプロパティを部分的に持つことは許されない。

type C = A | B;

const c :C = {type: 'a', a: "hoge", b: 1} // error

タグ付きの場合は A | B に対する TypeScript の型推論も単純になる。(例えば a.type == 'a' で分岐させれば以降は 型が A に定まる)

ちなみに、タグを重複させるとタグ無し共用体型みたいに部分的にプロパティを持つことが可能になる。というか多分タグ無し共用体型と同じ型定義になる気がする。

type A = {
  type: 'a'
  a: string
};

type B = {
  type: 'a'
  b1: number
  b2: number
};

type C = A | B;

const c :C = {type: 'a', a: "hoge", b1: 1} // ok

*1:形状は、書籍プログラミングTypeScript で使われている用語で、オブジェクトリテラル表記で宣言される型のこと。TypeScript のオブジェクト型の他、クラスインスタンスも含まれる。多分最終的に JavaScript のオブジェクト型に変換されるものは全て含まれる