✡️

集合で理解する Typescript

2024/06/15に公開

🌼 はじめに

私は高校の数学時間に初めて集合について教わりましたが、その時は全然知らなかったです。まさか Typescript に集合の知識を活かす日が来るとは、、

ということで今回は集合の観点で理解する Typescript について語ります。

1. 型は値の集合

変数には色んな種類の値を割り当てることができ、Typescript なので型を持ちます。

const A: number = 42
const B: null = null
const C: undefined = undefined
const D: string = 'Canada'
const E: boolean = true
const F: number[] = [1, 2, 3, 4]
const G: { [k: string]: string} = { name: "name" }

集合的に考えると、型は割り当てることができる値の集合ともいえます。例ば、すべての数値の集合は number 型です。上記のサンプルコードを集合のベン図で表したらこんな感じになるでしょう。

数字の 10number 型の要素で、文字列の ’10’number 型の要素ではないので割り当てることができません。

const num1: number = 10
const num2: number = '10' // Type 'string' is not assignable to type 'number'

Typescript のエラーでよく見る assignable と言う単語は「割り当てられる」という意味です。これを集合の観点で解釈すると、「〜の要素である」もしくは「〜の部分集合である」という意味でも捉えられます。

2. 型チェックは部分集合かどうかを見る

例えば以下のようなコードがあるとしましょう。

type AB = "A" | "B"
type AB12 = "A" | "B" | 12

const ab: AB = Math.random() < 0.5 ? "A" : "B"

const ab12: AB12 = ab // No Error

AB12 型の変数に AB 型の値を割り当てても、エラーにはなりません。理由は、集合で考えた場合 ABAB12 の部分集合であるからです。


AB12 型と AB 型のベン図

では AB12 の部分集合ではない型を割り当てたらどうなるでしょう?試しでコードを少し修正してみました。

type ABC = "A" | "B" | "C"
type AB12 = "A" | "B" | 12

const abc: ABC = Math.random() < 0.3 ? "A" : Math.random() < 0.7? "B" : "C"

// Type 'ABC' is not assignable to type 'AB12'.
// Type '"C"' is not assignable to type 'AB12'.
const ab12: AB12 = abc 

今回はバッチリエラーが発生しました。エラーメッセージが親切に "C" があるから AB12 には割り当てられんよって言ってますね。


AB12 型と ABC 型のベン図

つまり、集合の観点で型チェックはある集合が別の集合の部分集合であるかどうかチェックしてるということです。

3. 和集合(Union)

型は集合だと理解できたので、1番よく使われてる和集合の話をしましょう。

集合では、二つの集合を「くっつけ」て一緒にしてしまうことで新しい集合を取り出すことができ、それを和集合といいます。


和集合(Image from wikipedia

和集合は英語で Union です。Typescript ユーザーなら Union はすでに馴染みあると思います。サンプルコードを見てみましょう。

type A = "A"
type B = "B"

type AB = A | B // "A" | "B"

A は要素が文字列 "A" だけの集合で、B は要素が文字列 "B" だけの集合です。その2つの集合をくっつけて和集合を作ったら、"A""B" を要素としてもつ集合を作ることができます。


A 型と B 型の和集合

ちなみに上位集合と部分集合の和集合は上位集合です。

type ABC = "A" | "B" | "C"

type E = ABC | string // string

ABC に割り当てられる値は"A""B""C"で、全部文字列です。自然に ABCstring の部分集合になります。ではその2つをくっつけて和集合を作ったら、そりゃ上位集合である string になるでしょう。


ABC 型と string 型の和集合は string

集合の観点で考えると、以下のような Typescript の動作も理解できると思います。

type T = "A" | 10 | true

type S = T | string // string | 10 | true  
type N = T | number // number | "A" | 10  
type B = T | boolean // boolean | "A" | 10  

上記のコードをベン図に表してみました。ベン図みて和集合作ってみたら、難しくないでしょう!


左から SNB 型のベン図

4. 共通部分(Intersection)

和集合について説明したので、共通部分についでも説明します。

二つの集合の共通した部分を見つけることで、新しい集合を取り出すことができ、それを共通部分といいます。


共通部分(Image from wikipedia

共通部分は英語で Intersection です。Intersection も馴染みある方多いと思います。サンプルコードを見てみましょう。

type ABC = "A" | "B" | "C"
type CDE = "C" | "D" | "E"

type C = ABC & CDE // "C"

ABC"A""B""C" を要素として持つ集合で、CDE"C""D""E" を要素として持つ集合です。その2つの集合から共通した部分を見つけると、"C" を要素としてもつ集合を作ることができます。


ABC 型と ABC 型のベン図

また、上位集合と部分集合の共通部分は部分集合です。コードで見てみましょう。

type ABC = "A" | "B" | "C"

type E = ABC & string // "A" | "B" | "C"

和集合のときに見たサンプルコードと同じコードで、ABCstring の部分集合です。その2つから共通した部分を見つけると、そりゃ部分集合である ABC そのものになるでしょう。


ABC 型と string 型の部分集合は ABC

和集合の説明で見たサンプルコードを部分集合に差し替えてみても、集合のベン図を考えたら共通部分を見つけることは難しくありません。

type T = "A" | 10 | true

type S = T & string // "A"
type N = T & number // 10
type B = T & boolean // true

ベン図で考えたら簡単でしょう?


左から SNB 型のベン図

3. neverunknownany

次はちょっと特別な集合である neverunknownany について説明します。

never は空集合

never は空集合なので、なにも割り当てることができません。

let unknownValue: unknown
let anyValue: any

const A: never = 100 // Type 'number' is not assignable to type 'never'
const B: never = '100' // Type 'string' is not assignable to type 'never'
const B: never = '100' // Type 'string' is not assignable to type 'never'
const C: never = true // Type 'boolean' is not assignable to type 'never'
const D: never = null // Type 'null' is not assignable to type 'never'.
const E: never = undefined // Type 'undefined' is not assignable to type 'never'.
const F: never = unknownValue // Type 'unknown' is not assignable to type 'never'.
const G: never = anyValue // Type 'any' is not assignable to type 'never'.

空集合とか要るのか?と思うかもしれませんが、数字の世界でゼロが無の量を表すように、型の世界でも不可能を表す型は必要です。

例えば stringnumber の共通部分を見つけようとしたら、空集合になるでしょう。never 型が存在するおかげで、それが表現でます。

type T = string & number // never

unknown はすべての集合の上位集合

unknown は1番大きい集合で、どういう型も割り当てることができます。

const A: unknown = 100
const B: unknown = '100'
const C: unknown = true
const D: unknown = null
const E: unknown = undefined
const F: unknown = [100]
const G: unknown = { a: "a" }
const H: unknown = () => {}


ベン図で表すとこんな感じ

なんか、、unknownってanyと似てるような?気がする?、、と思うかもしれませんが、実はunknownanyには大きな違いがあります。

any はすべての集合の上位集合であり、部分集合でもある

大事なことなので2回いいます。any はすべての集合の上位集合であり、部分集合でもあります

どういうこと???って感じですけど、コードで見てみましょう。

unknownと同じく、any型にはどういう型も割り当てることができます。

const A: any = 100
const B: any = '100'
const C: any = true
const D: any = null
const E: any = undefined
const F: any = [100]
const G: any = { a: "a" }
const H: any = () => {}

これが「すべての集合の上位集合である」ということです。


ベン図で表すとこんな感じ

ただ、(never 以外の)すべての型に any を割り当てることできます。

let anyValue: any

const A: number = anyValue
const B: string = anyValue
const C: boolean = anyValue
const D: null = anyValue
const E: undefined = anyValue
const F: string[] = anyValue
const G: Record<string,string> = anyValue
const H: () => void = anyValue

これが(never 以外の)すべての集合の「部分集合である」ということです。


ベン図で表すと、、、こんな感じ、、、

わけわかんないですね。だから any は Typescript 世界観の破壊者なんですよ。

補足1) unknown は部分集合になれない

unknown は1番大きい集合なので、他の集合の部分集合にはなれません。

let unknownValue: unknown

const A: number = unknownValue // Type 'unknown' is not assignable to type 'number'
const B: string = unknownValue // Type 'unknown' is not assignable to type 'string'
const C: boolean = unknownValue // Type 'unknown' is not assignable to type 'boolean'
const D: null = unknownValue // Type 'unknown' is not assignable to type 'null'
const E: undefined = unknownValue // Type 'unknown' is not assignable to type 'undefined'
const F: string[] = unknownValue // Type 'unknown' is not assignable to type 'string[]'
const G: Record<string,string> = unknownValue // Type 'unknown' is not assignable to type 'Record<string, string>'
const H: () => void = unknownValue // Type 'unknown' is not assignable to type '() => void'

ただ any の部分集合にはなれる(any は破壊者だから、、 )

let unknownValue: unknown
const A: any = unknownValue

補足2) anyunknown って具体的にどう違う?

any - 何でもありなんで型チェックなんか要らん

以下のような関数があるとします。toUpperCasestring 型にだけ存在するメソッドなので、value が string 型じゃない場合確実にエラーになりますけど、value の型を any にすると、Typescript はエラーを出してくれません。

const toUpperCase = (value: any) => {
    return value.toUpperCase() // NoError
}

このように any は型チェックを放棄します。使わないようにしましょう。

unknown - 何の型が来るかわからんから、ちゃんと型確認しろよ

同じ関数でもvalue の型を unknown にしたら、エラーを出してくれます。これは型が何なのかわからないのに、string 型にだけ存在するメソッド使ったら危ないよ!と教えてくれてるということでしょう。

const toUpperCase = (value: unknown) => {
    return value.toUpperCase() // 'value' is of type 'unknown'
}

型ガードで型を string に絞ったら無事エラー解消することができます。

const toUpperCase = (value: unknown) => {
    if (typeof value !== "string") return
    return value.toUpperCase() // NoError
}

どういう型が来るかわからない時はだいたい unknown で解決できます。

4. オブジェクトの共通部分

今まで和集合と共通部分について話しましたが、これがオブジェクトの場合になると思ったことと違う動きに見えるかもしれません。それをじっくり解説していきます。

まず共通部分、Intersection から見てみましょう。

interface Person {
    name: string
}
interface LifeSpan {
    birth: Date
    death?: Date
}

// { name: string; birth: Date; death?: Date }
type PersonSpan = Person & LifeSpan

共通部分なのに、なんかオブジェクトのプロパティ増えてる?って思うかもしれません。先見たユニオン同士の Intersection は値が減ってたので、なぜオブジェクトは共通部分を作ったらプロパティが増えるでしょうか?

この原理を理解するためには、Typescript は Duck Typing に基づいてることを理解する必要があります。その話は以前別記事で扱ったことがあるので、ご参考お願いします。

https://zenn.dev/luvmini511/articles/2cd39b6cffa08c

Typescript の公式サイトでも詳しく解説していますのでぜひ読んでみてください。
https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes.html#structural-type-system

Duck Typing 観点で考えると、Personname: string プロパティをもつ(すべての)オブジェクトの集合で、LifeSpanbirth: Datedeath?: Date プロパティをもつ(すべての)オブジェクトの集合です。

その2つの共通部分を見つけようとすると?


PersonLifeSpan の共通部分

共通部分というものは、2つの集合両方の部分集合である必要があります。この考え方をDuck Typingに当てはめると、2つのオブジェクトのプロパティをすべて持つ型が共通部分となります。

5. オブジェクトの和集合

ではオブジェクトの和集合、Union はどうでしょうか。まず以下のような型があるとします。

interface OrderMade {
    size: string
    orderDate: Date
}
interface ReadyMade {
    size: string
    store: string
}

type Product = OrderMade | ReadyMade

そして Product 型の値を引数で受け取る関数を作ってみました。

product のプロパティにアクセスしようとしたら、size だけ補完で出てくることが不思議ですね。Union は和集合、範囲が広くなったはずなのになぜアクセスできるプロパティは減ってるんでしょうか?

ここで和集合の定義をもう一度確認します。


和集合(Image from wikipedia

和集合ということは、共通部分と違って必ず両方の集合の部分集合である保証がありません。つまり、片方だけの部分集合である可能性が存在します。

それを踏まえて、product にアクセスしようとしてるときの Typescript の気持ちをを考えてみましょう。

Typescript は思います。「この時点だと product の型が OrderMade のほうなのか ReadyMade のほうなのかわからない、、なら共通してるプロパティだけアクセスさせたほうが安全だ!」

ということでオブジェクトの和集合では、共通プロパティだけアクセスできるようになってます。

特定のプロパティにアクセスするためには、型ガードが必要です。

const doSomething = (product: Product) => {
    if ('store' in product) {
        console.log(product.store) // No Error
    }
}

でもプロパティごとに型ガードするのはだいぶめんどくさいので、オブジェクトの Union 型を使う場合は判別可能なユニオン型のほうが便利です。

判別可能なユニオンが何かというと、型を判別するためのプロパティを持ったオブジェクトの Union 型です。言葉だけだと難しいので、コードで見てみましょう。

interface OrderMade {
    type: "orderMade" // 型を判別するためのプロパティ
    size: string
    orderDate: Date
}
interface ReadyMade {
    type: "readyMade" // 型を判別するためのプロパティ
    size: string
    store: string
}

type Product = OrderMade | ReadyMade

const doSomething = (product: Product) => {
    if (product.type === "orderMade") {
        console.log(product.orderDate) // このスコープでの product は OrderMade 型
    }
    if (product.type === "readyMade") {
        console.log(product.store) // このスコープでの product は ReadyMade 型
    }
}

プロパティごとに型ガードするより、こっちのほうがもっとわかりやすいですね。

このように「型を識別するためのプロパティを持つオブジェクトのユニオン」を Discriminated Union、または Tagged Union といいます。

🌷 終わり

これで皆さんの Typescript への理解が深まったら嬉しいです。

この記事は以前勉強会で発表した資料を記事化したものです。ベン図描くのめんどくさかったです笑

GitHubで編集を提案

Discussion