Effective TypeScript: 62 Specific Ways to Improve Your TypeScript (English Edition) 1st 版, Kindle版
https://www.amazon.co.jp/Effective-TypeScript-Specific-Improve-English-ebook/dp/B07Z8HRZZ3
こちらの、歯ごたえのありそうな章を訳しつつ咀嚼してアウトプットする作業の殴り書きメモです。
静的型付け言語としては、JavaやScalaといった公称型の型システムしか触ったことがなかった私は、
- 型としてリテラルが平気で出てくる
-
&
や|
を駆使して柔軟に型定義する
というTypeScriptの型システムにイマイチ馴染めなかったのですが、この章を読むことでかなり腑に落ちることが多かったです。
TL;DR
TypeScriptでは、型を"値の集合"と捉えると、分かりやすい
以下、本文の流れに沿って解釈を加えたもの
TypeScript が JavaScript にコンパイルされ、実行される段階になると、変数が保持しているのは、もはや単一の値だけです。
単一の値というのは、例えば以下にあるようなものです。
・42
・null
・undefined
・'Canada'
・{animal: 'Whale', weight_lbs: 40_000}
・/regex/
・new HTMLButtonElement
しかし、コードが実行される前(というか JavaScript にコンパイルされる前)の段階、つまり TypeScript がエラーをチェックしているときには、変数は型を持っています。
これは、**「とりうる値の集合」**と考えると分かりやすいです。
型の領域、範囲と呼んでもよいでしょう。
例えば、number
型は、「あらゆる数値で構成された集合」と考えることができます。42
と-37.25
はこの中に入っていますが、"Canada"
は入っていません。(null
やundefined
をこの集合のメンバーとして扱うかどうかは、tsconfig.json のstrictNullChecks
の設定で変わります。)
いくつか型や、型の定義手法を見てみましょう。まずは領域の小さいものから。
never
最も小さい集合は、空集合です。これ値をひとつも含みません。TypeScript ではこれに相当する型はnever 型です。
空集合は範囲が空、つまりメンバーはひとつもないので、never 型の変数に代入できる値は存在しません。
const x: never = 12;
// Type '12'はneverに当てはめられない
リテラル型
次に小さな集合は、単一の値のみだけで構成されたものです。これは TypeScript でいうと、リテラル型に相当します。リテラル型は、単一の値のみを指すので、ユニット型とも呼ばれます。
type A = "A";
const a: A = "A"; // OK
const b: A = "B"; // Error! 型 '"B"' を型 '"A"' に割り当てることはできません。
type Zero = 0;
const zero: Zero = 0; // OK
const one: Zero = 1; // Error! 型 '1' を型 '0' に割り当てることはできません。
ユニオン型
2 個とか 3 個とかで構成された型を作りたかったら、ユニオン型と呼ばれる手法で、単一の値を和集合にして表現すればよい。
※細かい話ですが、用語としての「ユニオン型」は、「never型」「string型」といった特定の型とは異なり、「和集合を使って型を定義する手法」、あるいはその手法を使って定義した型の総称、を指します。(これは他のユーティリティ型とか、インターセクション型も同様)
type AB12 = "A" | "B" | 12;
const a: AB12 = "A"; // OK
const b: AB12 = "B"; // OK
const c: AB12 = "C"; // Error! 型 '"C"' を型 'AB12' に割り当てることはできません。
const twelve: AB12 = 12; // OK
const thirteen: AB12 = 13; // Error! 型 '13' を型 'AB12' に割り当てることはできません。
4 個以上も同じような感じでいける。ユニオン型は、値の集合の和集合に相当する。
エラー文の「型 '"C"' を型 'AB12' に割り当てることはできません。」を集合で考える
"割り当てることはできません"(not assignable)という言葉が TypesScript のエラー文でよく出てくるけど、これは、「集合のメンバーでない」あるいは「その集合の部分集合でない」である(だから、その集合の一員として、つまりその型として扱えない)ということ。
type AB = "A" | "B";
const x: AB = "A";
const y: AB = "B";
const z: AB = "C"; // 型 '"C"' を型 'AB' に割り当てることはできません。
値"C"
は、型AB
、つまり"A"
と"B"
でなる集合に含まれないのでエラーとなる。型チェックは、「この集合はこっちの集合の部分集合か?」という試すことを行っている。
例に出したような型は、有限個の値でできているから理解しやすいと思う。でも、実際仕事で出てくる型はたいてい、無限の領域をもっている。こっちの方が理解するのが難しいかも。
こんな感じの 2 パターンで捉えてみるといいかも
あり得る値が積み重なっている
type Int = 1 | 2 | 3 | 4 | 5; // 無限に続く整数...
持っているプロパティだけ定義されている
interface Identified {
id: string;
}
この interface については、「この集合(型)にメンバー入りできる条件の説明書き」って感じで考えるといい。
「id
という string 型のプロパティを持っているなら、Identified という値の集合のメンバーである」という感じ。
Item 4(構造ベースの型システムに慣れよう Get Comfortable with Structural Typing) で説明されているように、TypeScript の型システムでは定義されているもの以外のプロパティも自由にもつことができる。関数として呼び出すことも可能。(この辺は、Item 11 でまた詳しく)
※順序が逆ですが、いずれ Item 4, Item 11 についてもまとめようと思います。
インターセクション型
型を、「値の集合」として捉えることで、色々と理解しやくなることが多い。例えば、
interface Person {
name: string;
}
interface Lifespan {
birth: Date;
death?: Date;
}
type PersonSpan = Person & Lifespan;
この&
演算子は、二つの型の積集合を作る。積集合なので、その範囲は、Person と Lifespan 以下になる。条件が厳しくなる、といった方がわかりやすいかも。
Person
かつLifespan
ってこと。
Person であるために必要なプロパティ(id: string)を持つ
かつ
Lifespan であるために必要なプロパティ(birth: Date; death?: Date;)を持つ
という意味である。
const ps: PersonSpan = {
name: "Alan Turing",
birth: new Date("1912/06/23"),
death: new Date("1954/06/07")
}; // OK
もちろん
name, birth, death 以外のプロパティ持っていても全く問題ない。余計なプロパティ持っていても、それは依然 Personspan である。基本のルールは、以下である。
インターセクションで定義した型のプロパティは、構成要素である型のそれぞれのプロパティの和集合をもつ」
= Personspan は、Person のプロパティの集合{name}と Lifespan のプロパティの集合{birth, death?}の和集合{name, birth, death?}をプロパティとして持つ。(それ以外を追加でもってても OK)
keyof の挙動で、ユニオン型とインターセクション型についてがっちり理解しよう。
keyof は keyof <型>
という風に使う。これは、「あるオブジェクトがその型なら、必ず持っていると決めつけられるプロパティ群」を返すものだ。(typeof "hoge"のように、値に型判定に使うtypeof
とは全くの別もの。)
型keyof (Person&Lifespan)
と
型(keyof Person) | (keyof Lifespan)
は等しい。
const key1: keyof (Person & Lifespan) = "name"; // key1の型は"name" | "bith" | "death"
const key2: keyof Person | keyof Lifespan = "name"; // key1の型は"name" | "bith" | "death"
型keyof (Person | Lifespan)
と
型(keyof Person) & (keyof Lifespan)
はどちらも never で等しい。
「Person、Lifespan のどちらかではある、と言われても、確実に持っていると言えるプロパティは何一つない」
「和集合なので、緩くなる、広くなる。」
「だから、keyof で得られる値(=A|B 型であるために必要なプロパティの集合=A|B 型であるなら必ず持っていると決めつけていいプロパティの集合)は、A と B で共通のプロパティがない限りは、never になる」
const key1: keyof (Person | Lifespan) = "name"; // '"name"' を型 'never' に割り当てることはできません。
const key2: keyof Person & keyof Lifespan = "name"; // '"name"' を型 'never' に割り当てることはできません。
これらの式がなぜ成り立つのか、直感的に理解できるようになれば、TypeScript の型システムを理解するための大きな一歩を踏み出すことができるだろう!
exnteds
PersonSpan 型を記述する、より一般的な別の方法は、extends を使用することである。
※ extends は&
や|
と使いかたも違い、インターフェースやクラス定義の際に定義したシンボルの後ろにつけるもの。&
や|
は、型や値を使って新しい型を表現するときに使う。
interface Person {
name: string;
}
interface PersonSpan extends Person {
birth: Date;
death?: Date;
}
型を「値の集合」と考えると、extends Person
は何を意味するのだろう?
これは、「Person の部分集合である」と捉えればよい。
PersonSpan のすべてが、以下 2 つをどちらも満たす。
-
string型の
name
プロパティを持つ -
Date型の
birth
プロパティを持つ
PersonSpan
型は、Person
型のサブタイプである、という言い方をしてもよい。これも、「PersonSpan
の範囲は、Person
の範囲の部分集合である」という意味で、言っていることは同じ。
1,2,3 次元のベクトルを定義することになったとしよう。
interface Vector1D {
x: number;
}
interface Vector2D extends Vector1D {
y: number;
}
interface Vector3D extends Vector2D {
z: number;
}
これを見て「Vector3D
はVector2D
のサブタイプで、Vector2D
はVector1D
のサブタイプ」と言うだろう。("サブクラス"という言葉でもよいが)
こういう関係性は、よく下記のような階層構造として、表される。
しかし、下記のヴェン図の方がより直感的だ。
ヴェン図だと、どの型がどの型の部分集合なのか、という関係性がわかりやすいし、下記のようにコードを書き換えても、関係性についてはなんら変わらないと簡単に理解できるだろう。
interface Vector1D {
x: number;
}
interface Vector2D {
x: number;
y: number;
}
interface Vector3D {
x: number;
y: number;
z: number;
}
それぞれの集合の領域は変わらず、ヴェン図も全く変わらない。
階層構造・ヴェン図はどちらの表現方法も有効だが、ヴェン図を使った集合論的な解釈はずっと直感的だ。とくに領域が有限なリテラル型やユニオン型を扱うときには。
extends
には interface や class をある型の部分集合である、と示すほか、ジェネリクス型の制限としても使われる。そこでの意味も「○○ の部分集合である」だ。
function getKey<K extends string>(val: any, key: K) {
// ...
}
extends string
とはどういう意味だろう?階層構造的に考えると、解釈が難しい。
一方、集合で考えるとすごく簡単明解だ。「string型の部分集合であれば、なんでもいい」これは string リテラルや、string リテラルのユニオン型、そして string そのものが含まれる。
getKey({}, "x"); // OK, 'x' extends string
getKey({}, Math.random() < 0.5 ? "a" : "b"); // OK, 'a'|'b' extends string
getKey({}, document.title); // OK, string extends string
getKey({}, 12);
// ~~ Type '12' is not assignable to parameter of type 'string'
最後の行の"not assignable"は、つまり「string の部分集合でない」という意味だ。繰り返しだが、「TypeScript において、not assignable(割り当てることができない)とは、部分集合でないという意味だ」と分かっていれば混乱することはない。この心構えは、keyof X
(X のプロパティの集合を表す型を返す)の戻り値のように、有限個の集合を扱うときにも助けになる。
以下のような Point 型を、x, y のどちらか任意のものでソートできる関数を作るとする。
type Point = {
x: number;
y: number;
};
const sortBy = (points: Point[], key: keyof Point): Point[] => {
return [];
};
const pts: Point[] = [{ x: 1, y: 1 }, { x: 2, y: 0 }];
sortBy(pts, "x"); // OK, 'x' は 'x'|'y' (keyof Tのこと)の部分集合
sortBy(pts, "y"); // OK, 'y' は 'x'|'y' の部分集合
sortBy(pts, Math.random() < 0.5 ? "x" : "y"); // OK, 'x'|'y' は 'x'|'y' の部分集合
sortBy(pts, "z"); // ~~~ 型 '"z"' の引数を型 '"x" | "y"' のパラメーターに割り当てることはできません。'z' は 'x'|'y' の部分集合でない!!
集合論的考え方を当てはめるのに、必ずしも階層構造の関係である必要はない。
例えば、string | number
とstring | Date
の関係性はなんだろう?この 2 つには共通部分はある(string
型の領域)。しかし、お互いに部分集合ではないので、階層構造で表すことは難しいが、2 つの集合としての領域の関係は明らかである。
配列とタプル
集合的な捉え方は、TypeScriptにおける配列とタプルの関係性も明らかにしてくれる。
const list = [1, 2]; // Type is number[]
const tuple: [number, number] = list;
// ~~~~~ Type 'number[]' is missing the following
// properties from type '[number, number]': 0, 1
number[]
を[number, number]
に割り当てることができない。number[]
であることが、そのままタプル[number, number]
であることを意味するか?いや、そんなことはない。空配列[]
や[1]
や[1, 2, 3]
のように、numebr[]
だが、[number, number]
ではない値は存在するからだ。なので、number[]
型を[number, number]
型に割り当てることができない、というのは妥当だ。なぜならnumber[]
は[number, number]
型の部分集合でないから。逆はしかりだ、下のヴェン図のように。
では、長さ 3 のタプルは([number, number, number]
)は長さ 2 のタプルに割り当て可能だろうか?
構造ベースの型システム的な観点で考えると、割り当て可能と思うかもしれない。「長さ 2 のタプルは0
と1
のプロパティを持っており、他のプロパティ、例えば2
を持つこともできるのではないか?」という感じで。
const triple: [number, number, number] = [1, 2, 3];
const double: [number, number] = triple;
// ~~~~~~ '[number, number, number]' is not assignable to '[number, number]'
// Types of property 'length' are incompatible
// Type '3' is not assignable to type '2'
しかし、興味深いことに、答えはノーだ。
なぜなら TypeScript では、タプルを
{0: number, 1: number}
ではなく、
{0: number, 1: number, length: 2}
として扱っているのだ!
[number, number]
はリテラル(2
)型のプロパティlength
を持つ必要があるが、[number, number, number]のlength
は3
であり、[number, number, number]
は[number, number]
の構造を含んでいないので、他のケースと同じように、構造ベースの型システム的に割り当て不可能である。
これは、[number, number, number]
は、[number, number]
の部分集合でない、ということと同義である。
type Tuple = [number, number]
const triple: Tuple = [1, 2, 3] // ~~~ 型 '[number, number, number]' を型 '[number, number]' に割り当てることはできません。
// プロパティ 'length' の型に互換性がありません。
// 型 '3' を型 '2' に割り当てることはできません。
[number, number]の長さは常に 2 であるべきだし、この挙動は妥当だろう。
ヴェン図はこうなる。
もし型を値の集合として捉えるべきなら、2 つの型が同じ範囲を占めているときは、同じ型と言えることになるし、実際その通りである。
しかし、セマンティクスが異なっており、たまたま同じ範囲になっている場合は、別々の型として定義することに意味はある。
TypeScript の型定義の限界
最後に、TypeScript の型があらゆる集合に対応しているわけではないことに注意しておこう。「すべての整数」を表す TypeScript 型は存在しないし、「x
とy
というプロパティを持つがそれ以外のプロパティは持たないオブジェクト」を表す TypeScript 型も存在しない。
Exclude
を使って、要素を減らした型を定義出来るが、これが意図通りに動くのは、「要素を減らした型が、依然 TypeScript で表現可能なものである」場合である。自由自在に柔軟に補集合が作れるわけではない。
type RGB = "red" | "blue" | "green";
type RG = Exclude<RGB, "green">; // "red" | "blue" という型はTypeScriptで表現可能なので、うまくいく。
const r: RG = "green"; // ~~~ '"green"' を型 '"red" | "blue"' に割り当てることはできません。
type NumberNotZero = Exclude<number, 0>; // 0以外の`number`という型をTypeScriptで表現することはできない。なのでこの型定義に効果なし!
const n: NumberNotZero = 0; // エラーにならない!
※後の方ができない理由は、「number という型を定義するときに、0 という値に言及をする必要がないから」と言い換えてもいいかもしれない。
型表現と集合の対応表
最後に、それぞれの型・型についての表現と、集合の関係をもう一度整理しておこう。
まとめ
-
TypeScript においては、型を「値の集合」と考えよう。型の占める領域をイメージしよう。この領域は有限である場合と無限の場合がある。
-
&
を使ってつくるインターセクション型は、階層構造ではなく、ヴェン図でイメージしよう。インターセクション型は、サブタイプの関係になくても、共通部分を表現することができる。 -
型に所属している値は、その型が言及していないプロパティを持つこともできることを覚えておこう。(構造ベースの型システム)
-
&
や|
といった型演算子は、集合の範囲に適用される。A & B
は A の範囲と B の範囲の積集合である。A も B もオブジェクトだとすると、A & B
は、A と B のプロパティ両方をもつ。 -
T1 extends T2
、"T1 is assignable to T2"(T1 を T2 割り当てることができる)、"T1 は T2 のサブタイプである" これらはすべて「A は B の部分集合である」と解釈しよう