TypeScript における object の intersection (&)

Understanding the intersection in Typescript might be harder than you think を読んで改めて TypeScript の intersection ( & のこと ) について「ふーん」となったのでメモ
※このブログ中の画像は全て上記の記事から転載している

型を集合 (より正確にいうと集合にまつわる数学) として捉えると考えやすいというのは、様々なところで言われているのでここでは細かく説明はしない
また集合における intersection とは A ∩ B (集合Aと集合Bの共通部分) のことである

TypeScript の型においては、A & B とは A ∩ B と同じことであり、すなわち 型Aが取りうる値の集合と型Bが取りうる値の集合の共通部分 と言える
詳しくは後述していくが 取りうる値の集合 というのが最大のポイントである

どういうことかというと、

type Hero = string;
type Villain = "Lex Luthor"

上記の例で Hero という型の取りうる値の集合は無数の string となる ( e.g. spiderman, batman , …. )。
一方で Villain という型の取りうる値の集合は具体的な string の値 "Lex Luthor" 1つだけである。

type Hero = string;
type Villain = "Lex Luthor"
type Intersection = Hero & Villain; // "Lex Luthor"

つまり Hero & Villain という共通部分は "Lex Luthor" である

では、以下のような場合はどうなるかというと、 HeroVillain の取りうる値の集合に共通部分はないため空集合 ( never ) となる。

type Hero = "Clark Kent";
type Villain = "Lex Luthor"
type Intersection = Hero & Villain; // never

object における intersection

type Hero = { hero: "Clark Kent" };
type Villain = { villain: "Lex Luthor" };
type Intersection = Hero & Villain; // { hero: "Clark Kent", villain: "Lex Luthor" };

さて本題にはなるが objectintersection になると話がちょっとだけややこしくなってくる
上記の Hero & Villainintersection は型としては { hero: "Clark Kent", villain: "Lex Luthor" } となる。
直感的には、 HeroVillain には共通部分がないように見えるし、一見共通部分ではなく和を取ったような見え方になっている (そう見えない人もいるかもしれないが)。

これも 「取りうる値の集合の共通部分」 であることに目を向けると説明がつく

type Movie = { title: string };

// 以下は全て Movie という型に対して代入が可能
const movie = { title: "The Dark Knight" };
const movie = { title: "The Dark Knight", villain: "Joker" };
const movie = { title: "The Dark Knight", year: 2008, imdbUrl: "https://www.imdb.com/title/tt0468569/", hasGoodEnding: boolean; numberOfViewers: 13 };

上記のように object の性質として雑にいってしまうと (key, value) の型が一致しているものが少なくとも1つあれば型に対して整合性が取れていると考える。

したがって、 「取りうる値の集合」 としては、 { title: "The Dark Knight" } さえ含まれれば無数に取ることが可能な集合であると考えることができる。
(上記の movie が全て Movie に対して適合であることからも言える)

type Hero = { hero: "Clark Kent" };
type Villain = { villain: "Lex Luthor" };
type Intersection = Hero & Villain; // { hero: "Clark Kent", villain: "Lex Luthor" };

そこで改めて上で挙げた例を再掲する

「取りうる値の集合の共通部分」 という見方をすると
以下の画像のように { hero: "Clark Kent", villain: "Lex Luthor" } が共通部分であることがわかり納得がいくと思う。

(余談) string & { name: string }

type Intersection = string & { name: string }

さてこの intersection はどうなるでしょう?
これまでの説明からすると string{ name: string } には共通で取れる値がないので空集合となる気がしてしまうが実際には never にはならない。

実は、Why is it allowed to intersect primitives and objects in TypeScript? - Stack Overflow でも回答されているように常に 空集合 = never となるわけではない。
どういうことかというと このコメント にもある通り、 never にするにもそれなりの計算コストがかかるので絶対起き得ないようなケースまでは厳密には処理しないとしている。
(この辺りも非常に Pragmatic で TypeScript っぽいなと思う)

また今回の場合には実は Branded Type というユースケースというかテクニックが存在するからという側面もある。

type SomeUrl = string;
type FirstName = string;

TypeScript では例えば上記のように書いたとしてもどちらも string として処理されるためお互いに代入が可能となってしまう。
特に昨今においては Kotlin における Value Class など SomeUrlFirstName はお互いに代入不可となるように型を分けたいことが多々ある。

Branded Type はまさにこのユースケースのために以下のように書くことで型が異なるためお互いに代入不可とするテクニックである

type SomeUrl = string & { __brand: 'SomeUrl' };
type FirstName = string & { __brand: 'FirstName' };

let x = 'http://www.typescriptlang.org/' as SomeUrl
let y = 'Bob' as FirstName

x = y // error となる

まとめ