集合で理解する Typescript
🌼 はじめに
私は高校の数学時間に初めて集合について教わりましたが、その時は全然知らなかったです。まさか 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
型です。上記のサンプルコードを集合のベン図で表したらこんな感じになるでしょう。
数字の 10
は number
型の要素で、文字列の ’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
型の値を割り当てても、エラーにはなりません。理由は、集合で考えた場合 AB
は AB12
の部分集合であるからです。
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"
で、全部文字列です。自然に ABC
は string
の部分集合になります。ではその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
上記のコードをベン図に表してみました。ベン図みて和集合作ってみたら、難しくないでしょう!
左から S
、N
、B
型のベン図
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"
和集合のときに見たサンプルコードと同じコードで、ABC
は string
の部分集合です。その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
ベン図で考えたら簡単でしょう?
左から S
、N
、B
型のベン図
never
、unknown
、any
3. 次はちょっと特別な集合である never
、unknown
、any
について説明します。
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'.
空集合とか要るのか?と思うかもしれませんが、数字の世界でゼロが無の量を表すように、型の世界でも不可能を表す型は必要です。
例えば string
と number
の共通部分を見つけようとしたら、空集合になるでしょう。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
と似てるような?気がする?、、と思うかもしれませんが、実はunknown
とany
には大きな違いがあります。
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 世界観の破壊者なんですよ。
unknown
は部分集合になれない
補足1) 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
any
と unknown
って具体的にどう違う?
補足2)
any
- 何でもありなんで型チェックなんか要らん
以下のような関数があるとします。toUpperCase
は string
型にだけ存在するメソッドなので、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 に基づいてることを理解する必要があります。その話は以前別記事で扱ったことがあるので、ご参考お願いします。
Typescript の公式サイトでも詳しく解説していますのでぜひ読んでみてください。
Duck Typing 観点で考えると、Person
は name: string
プロパティをもつ(すべての)オブジェクトの集合で、LifeSpan
は birth: Date
と death?: Date
プロパティをもつ(すべての)オブジェクトの集合です。
その2つの共通部分を見つけようとすると?
Person
と LifeSpan
の共通部分
共通部分というものは、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 への理解が深まったら嬉しいです。
この記事は以前勉強会で発表した資料を記事化したものです。ベン図描くのめんどくさかったです笑
Discussion