型システム好きの人間の『プロを目指す人のためのTypeScript入門』読書メモ
AI全盛の時代に
業務でTypeScript(JavaScript)エコシステムにそれなりに関わりそうだったので、知識がないまま変なことして仕事を難しくしないために読んだ。
猫も杓子もAIの時代にプログラミング言語の入門書を読む意義がどれくらいあるかはわからない。他人に勧めもしないし、実際業務でもコードを書くのはほとんどAIかもしれない。
それでも自分が読みたかったのはいくつか理由がある。
まず第一に、純粋にTypeScriptに興味があり、AIに書かせるから詳しく知らなくていいという態度はもったいないと感じるから。 JavaScriptという、既に莫大な規模のエコシステムと歴史を持つ言語に対して型を後から付けるというかなり野心的な試みをしていて、普及率を見るとそれは成功しているように見える。 この世界ではとにかく利用者数が多いことが一つの正義だが、そういった技術が必ずしも「よい」性質を持っているとは限らない。そういった点TypeScriptは稀有な技術であり、興味を引く。
第二に、そうはいってもまだまだ概観を掴んでいくことには意味があると思うから。自分はTypeScriptのコードをただ書くだけじゃなくて、これで記述されたシステムを安定的に稼働させ続けなくてはいけない。 具体的には、このシステムが依存している外部要因の日々の変化を吸収し、同じ体験、価値をエンドユーザーに提供し続ける必要がある。あるいは、このシステムがもっと短いサイクルで安全に楽にリリースできるようにCI/CD環境を整備しないといけない。 こういったことを念頭に置いたとき、やはりコアとなるような仕様や背景はなんとなくでもいいので理解しておいた方がよいと思う。基本的なことは自分の頭に入れておいた方が、より複雑なタスクを行うときにつかえるワーキングメモリ領域が空く。
読書感想ブログとして
自分はいつも(歯磨きや通勤中に見返せるように)メモを取りながら本を読んでいて、それを再構成して感想ブログにしている。でも今回はそのまま転記する形にする。
というのも、元の本が基礎の網羅的な色合いのある入門書だったので、変に捨象してしまうよりも、それぞれの要素に対して自分がどのように反応したか実況するような体裁のほうが、第三者の目線に立った時に面白そうな気がしたからだ。
特に自分は修士で研究分野にするほど型システムが好きで、一方で普段の仕事はインフラ、バックエンドよりなのでフロントエンドに非常に疎い。そんな偏ったスキルセットを持った人間のTypeScript初見反応集みたいな感じになってると嬉しい。
読書メモ
# 1. イントロダクション
- typescriptにはシグネチャが違う同名関数を定義することはできない
- double(value: string), double(vlaue: number)が両方定義できるみたいなやつ
- これができるとtypescriptの原則「ランタイムの挙動が静的型に依存しない」に違反することになる
- javascriptのサブセットなので、javascriptにできないことを型情報を加えることでできるようにしたら困る
- これならできる
```ts
function double(value: string | number) {
if (typeof value === "number") {
console.log(value * 2)
} else {
console.log(value.repeat(2));
}
}
double(123);
double("hello");
```
- これも実行時に型の影響を受けてるという意見があるかも
- これは動的型や型タグと呼ばれるもので静的型とはちがう
- まぁこれはjavascriptでできないことをできるようにしているわけじゃないから前述の方針に違反していないのはわかる
- でもtsの静的型とjsの動的型になんらかの対応がないといけないよね?
- この説明だと完全に解決していないというか、別の問題に帰着させただけというか
- tsのコンパイルは単に型注釈に関わるところを消すだけ
- 型注釈によって変換先がことなるということはない
- ECMAScript
- javascriptとほぼ同一と考えてよい
- 言語仕様としての側面でECMAを使うことがおおい
- 例えば
- ??演算子はECMAScript2020の機能だとか
- JavaScript2020とかは言わない
- TSとECMAScriptの関係
- ECMAScriptの機能はプロポーザルという単位で管理される
- ステージ4つある
- 4で承認
- TSは3の段階でサポートする
- TS独自の機能は使うべきではない
- 今でこそTSの設計方針は JS + 型だったけど初期のTS開発の名残で独自機能がある
- enum, namespace
- 今のTSでは独自機能は追加しないことになっている
- 筆者の意見はこういうのは使わないようにするべき
- これが必須になる場面は多くない
- 必要以上に複雑になる
- あとまぁ公式が独自機能追加やめてるならそっちの方がいい気もするよね
- 開発環境
- コマンド
```sh
pnpm init
```
```sh
pnpm add -D typescript @types/node
```
- やってること
- typescript
- tscをダウンロード
- @types/node
- Node.jsの型定義(fs, path, processなど)を TypeScript に教えるためのもの
# 2. 基本的な文法・基本的な型
- nullとundefined
- どちらもプリミティブ
- どちらも値がないことを表現する
- undefinedを使うべき
- typescriptのサポートがあついから
- `==`と`===`について
- 前者はよほどの例外を除いて使うべきじゃない
- 後者の方が厳密
- == は暗黙の型変換を行う
- こわ過ぎ!w
# 3. オブジェクトの基本とオブジェクトの型
- TypeScriptのオブジェクトの半分を扱う
- 残りはクラス
- フィールドの値がすべて同じでもメモリを共有した?同じものじゃないと===がtrueにならない
- え、これDDDの値オブジェクトどうするの
- equalsを自分で定義する必要がありそう
- 地味にめんどうだ(値オブジェクトがネストされた構造になることはよくあり、その最後までequalsが正しく実装されているかチェックしないといけないから)
- type文はただのエイリアス
- rustと同じやね
- newtype パターンじゃない
- interface宣言
- type文で代替可能でこっちが使われるらしい
- そうなんや
- インデックスシグネチャ
- オブジェクト型に関する特殊な記法
- プロパティの名前を抽象化できる
```ts
type PriceData = {
[key: string]: number;
}
```
- number 型であるようなフィールドをいくつも持てることを示す
- でもこれは型システムの恩恵にあずかれなくなるので利用は非推奨
- この定義からすると、以下の2つの型が同じになっちゃう
- a: number だけもってる
- b: number, c: number をもってる
```ts
type MyObj = { [key: string]: number };
const obj: MyObj = { foo: 123 };
// これは直観的には型エラーになってほしいけどならず、undefined が取得できる
// やばい
obj.bar;
```
- tsの値はプリミティブかオブジェクトしかない
- 配列はオブジェクト
# 4. TypeScriptの関数
- 関数式の定義がアロー関数ともうひとつある
- アローの方が新しい
- オブジェクトのメンバーとして関数定義もできる
- TypeScriptの型推論
- Hindley-Milnerに比べると弱い
- コールシグネチャ
- 共変、反変
- オブジェクトのフィールドに関数を定義する際
- 通常の関数型`(A => B)`じゃなくてメソッド記法`(A: B)`で定義すると
- 双変関係になり、部分型関係がゆるくなる
- これは型安全性を壊すので使わない方がいい
- 歴史的経緯で残っている
- 苦しい
- readonlyプロパティを持つ型の部分型関係について
- 配列に関しては直観がなりたつ
- つまり `T[] <: readonly T[]` がなりたつ
- ただし object の property に関しては readonly 型の部分型関係が非健全で壊れている
- property の直接の書き換えはチェックされるが
- 関数の引数を通すと変更できちゃう
- つまり、object を受け取ってそのフィールドを書き換えるような関数
- それに当該フィールドが readonly であるようなオブジェクトを渡せちゃう
- Rustの所有権のありがたさ
# 5. TypeScriptのクラス
- アクセス修飾子
- privateについて
- プロパティ名の最初に `#` をつけることで同じ機能を果たせる
- private(, public, protected)は typescript の機能
- `#` は ES2015 (JavaScript) の機能
- どっちを使うべき?
- 厳格なのは `#`
- json.stringfy とかでも出力されない or されちゃうの違い
- ここでも ts の型システムの限界
- 厳格にしようと思ったら見た目がいびつ(public, '#' が入り乱れる)なコードを書かないといけない
- クラスの不思議
- クラス宣言でUserというクラスを作る
- Userクラスそのものは「クラスオブジェクトが入った変数User」
- これなじみがなくておもろい
- 同時に、Userという型も作られている
- クラス宣言じゃなくてクラス式で作ると型が作られなくなる
- instance of 演算子
- これは、instance of C で Class C のインスタンスであるか?という意味
- C 型だけど、C のインスタンスじゃない場合は false になるので注意
- 一方、c instance of C = true ならば c: C なので、型の絞り込みにも使える
- ↑これ例外ない?CのインスタンスだけどC型じゃないものがあればこわれない?
- ほぼ大丈夫っぽい
- ただ、ts のビジネスロジックにこれ使うのは非推奨らしい by 著者
- 型システムのおんけいにあずかるべきで、これはクラスでちょっと違う & 紛らわしいみたいな感じ
- 継承
- これを使うと設計が複雑になる傾向にある
- できることが多すぎる
- ts はそもそもクラスがなくてもなんとかなる
- あんまりつかわないほうがよさそう
- implements キーワード
- 型注釈に似ている
- つけなくても ts はかってに部分型になっているならそうなる
- 付けるとより明示的で、間違ってるとエラーを出してくれる
- prototype
- あるクラスのインスタンスを2つつくる
- そのインスタンスのメソッドは同じ
- `instance1.method === instance2.method`
- この挙動を実現しているのが prototype
- 本書では割愛
- へー、ここででてくるんや
- this
- これを使ったメソッドは基本的にメソッド呼び出しで呼び出すべき
- そうしないとランタイムエラーになる
- 例えば const method = instance1.method から method(hoge)
- みたいな変な使い方をして呼び出すと
- クラスの専売特許ではない
- オブジェクトに定義されたメソッドないでも this を使える
- 型システムの秘孔をつくケースがある
- またか。。。
- err, try-catch
- できるだけつかわない
- まぁRustの`Result<T, E>`みたいなやつをこっちでもつかったほうがいいよね
# 6. 高度な型
- ユニオン型
- 直和型とは似ているけど違う
- こっちはいうなればタグ付きユニオン
- どの型なのか、タグで判別できる
- こっちの方が高機能か
- インターセクション型(a.k.a 交差型)
- S & T とかく
- オブジェクト型同士だと合成のような直観
- S型でもありT型でもあるような型
- プリミティブ同士だと never 型になる
- 空集合
- オブジェクト型 & プリミティブ型
- これは必ずしも空集合になるわけじゃないので never 型には推論?されない
- が、ほとんどの場合は空集合(それでも never 型として計算?されるわけじゃない)
- optional chain
- T | undefined, あるいは T | null 上の? モナドっぽい
- gettimeFunc?.().toString();
- 途中で undefined が返ると toString もスキップされる
- リテラル型
- 4種類
- 数値
- 文字列
- 真偽値
- BigInt
- テンプレートリテラル型
- string の部分型
- そのテンプレートリテラルが表現する任意の文字列がその型になる
- `Hello, ${string}!` という型
- リテラル型のwidening
- const hoge1 = "hoge" は "hoge"型
- let hoge2 = "hoge" は string 型
- let はプリミティブ型に推論される
- 再代入可能なので、こっちのほうが合理的だね
- オブジェクトのフィールドとして登場した場合もプリミティブ型に widening される
- これも同じモチベーション
- オブジェクト自体が const で定義されていても、その中身は書き換えられるので
- readonly を使って回避できるという話だったね
- 非常に直観的に設計されているから混乱することはなさそう
- 型の絞り込み(control flow analysis, コントロールフロー解析)
- ユニオン型の例
- どのヴァリアントなのかをランタイムに特定するコードを書くと、型情報がそれをもとに推論の精度をあげる
- `if b { e }` において、e を静的解析する際に、b がなりたつという情報を使ってくれる
- これ control flow analysis っていうんだ。おもろい、すごい
- 代数的データ型
- 残念ながら直和型は ts にはない
- ただ以下のようにして一部模倣可能
```sh
type Animal = {
tag: "animal",
species: string,
}
type Human = {
tag: "human",
name: string,
}
```
- tag の型はリテラル型になってる
- if 文による条件分岐で型の絞り込みができる
- できないこと
- コンパイラによる網羅チェックはできなそう
- switch文にするとできる
- それでもやっぱり「うまくつかえば」いい機能の恩恵にあずかれるというのは恩恵がすごく薄い
- 守られないから
- こういう「人にやさしいシステム」はチーム開発にこそもちこみたい
- But
- その開発の立ち上げにここら辺に詳しい人がいないと組み込まれない
- 詳しい人がチームからいなくなると運用中にその仕組みがなくなる可能性もある
- 採用などで一定のレベルを担保することもできるけど、やはり誰がアサインされるかにかかわらずシステムがプログラマが守ってくれる方がいい
- lookup型
- 型 T, K に対して T[K] と書く
- これが well-formed になる一例は T がオブジェクト型で、K は文字列リテラル型
- 意味は T 型のオブジェクトのフィールド名が K であるフィールドの型
- 他に well-formed な例はあるのかな
- いろいろある
- T["field1" | "field2" ] みたいな書き方もできる
- => T["field1"] | T["field2"]
- K が keyof T に含まれる型であればよい
- これもおもしれ~
- keyof型
- keyof T として well-formed
- Tには ts の型として well-formed な任意の型が入るっぽい
- 要は、Tのプロパティとして使える識別子をリテラル型として、ぜんぶ | で合成したユニオン型
- おもろい
- as : 型アサーション
- できればつかいたくない
- ts の型の保証から逸脱するので
- ts の型システムに、as T とかくことで、この値は T 型だといいはって信じてもらう
- これが嘘でもいいので、こわれちゃう
- Rust の unsafe みがあるね
- ts の例も学んで直観が養われていく
- じゃあいつ使うねん
- ts の型システムの限界を埋めるため
- 人間ならわかるけど、tsの推論ではそうとは結論できないところで、人間が結論付けてしまう
- 型システムの設計してるとよくあるね
- プログラムの解析がうまければもっと強い性質を保証できるけど、難しくてできない。。。みたいな
- それをプログラマ側で補うことができる
- 当然間違った判断を下す可能性もあるので unsafe というわけ
- `<式>!` という形で null や undefinedeの可能性を無視する
- これも特殊なアサーションである
- as const
- これはほぼ頻出で、逆に安全性を高めてくれる
- 直観
- これがつけられた式に登場する各種リテラルを「変更できない」ものとして扱う
- 具体的には
- 1. 配列リテラルの型推論結果をタプル型にする
- 2. オブジェクトリテラルから推論されるオブジェクト型はすべてのプロパティが readonly になる
- 3. 文字列、数値、BigInt、真偽値リテラルに対してつけられるリテラル型が、widening しないリテラル型になる
- 4. テンプレート文字列リテラルの型が string ではなくテンプレートリテラル型になる
- any
- as 型アサーションより危険
- 基本は避けるべき
- これがついている値に対して、ts は型に関して何の保証も与えない
- unknown
- any と似ている
- ただし、型システムがちゃんと解析に使ってくれる
- 何が入っているかわからないとして扱うのでできることがほとんどない
- ts はコントロールフロー解析ができるので型の絞り込みを行えば絞り込んだ先で突っ込んだ処理ができる
- あとでのべるユーザー定義型ガードもつかうとよし
- tsの高度な型システム機能はまだまだある
- このあとはなぞる
- 全部?抜けもありそう
- object型
- プリミティブ以外すべてがこの型の値である
- `{}` 型とはイコールじゃない
- これには null, undefined も入るため
- never型
- 矛盾型てきなやつ
- 所属する値がない
- 命題論理の矛盾命題からの連想で理解できるように設計されていてgood
- 型述語(ユーザー定義型ガード)
- control flow analysis をサポートするために、プログラマが型システムに情報を渡す
- この関数が成功したら、この値はこの型と思っていいです
- この関数の正しさの保証はプログラマの仕事
- なので本質的には as, any と同じ unsafe
- でもこの中で使うなら圧倒的にこれ
- 実行時に v が T であることをチェックできないといけないので、実行時に消えちゃうものに T の特徴づけ? が含まれている型に対してはつかえなそう
- とはいえ意外と多くの型にたいしてできそうではある
- フォーマット
- 返り値の型だけが特殊なフォーマットの関数で、使うのも関数と同じ
- 2種類ある
- 引数をもらって value is T という返り値の型を書く
- 実質の返り値は boolean
- true が返った先の処理では、型システムは value を T 型として判断する
- 引数をもらって asserts value is T という返り値の型を書く
- 実質の返り値が void
- T じゃないときは、例外を投げるように定義する
- この関数が正常に終了した先の処理では、型システムは value を T 型として判断する
- 失敗したときに例外が投げられるのでハンドリングする必要あり
- mapped types
- conditional types と並んで ts の2大ややこしい機能
- フォーマット
- `{ [P in K]: T }`
- P は型変数
- K はプロパティになれる型 ( string | number | symbol の部分型)で、ユニオン型
- T は任意の型で、P をふくんでよい
- 意味
- K というユニオン型の各構成要素 P に対して、P というプロパティが型 T を持つようなオブジェクトの型
- 感想
- 数学的な記号の操作に慣れていれば理解は容易そう
- むしろ下手にわかりやすくしようといろいろな中間概念持ち出してかえって認知負荷高まるよりすっきりしててとてもいい
- K = U1 | U2 | ... | Un として、以下の省略記法であるというだけのこと
```ts
{
U1: P(U1),
U2: P(U2),
...
Un: P(Un)
}
```
- 使用例
```ts
type Fruit = "apple" | "orange" | "strawberry"
type FruitNumbers = {
// number のところに P が出現してもいい
[P in Fruit]: number
}
```
- conditional types
- X extends Y ? S : T
- これも簡単
- 著者がややこしいといったのは、定義の理解じゃなくて、これらがもつ抽象的な性質をうまく利用したユースケースのことかもしれない
- 項に対してできた計算を型でもできるようにしている
- ts は型レベルの演算が豊富ですごい、野心的だね
- 規模の経済が働くソフトウェア業界のツール事情において、普及しているツールがこういう機能を野心的に取り入れるのは、業界全体のレベルアップにつながっていいとおもう
- X がユニオン型だと準同型写像になるらしい、きもちいい
- 写像
- CT[X] := X extends Y ? S(X) : T(X) とかける
- SとTの部分にはXが出現してよいので明示的にS(X)、T(X)と書く
- X = A | B みたいなユニオン型とすると
- CT[A | B] = CT[A] | CT[B] がなりたつ
- うれしいね
- 具体的にどういうときに嬉しいかはぱっと思いつかない
- でも数学的な性質を持つということは、膨大な研究成果を持ちきれいな体系を持つ数学の世界に帰着できるということなので、いろんな嬉しい性質をもっていることがおおいという信仰がある
- 組み込みの便利な型
- mapped type や conditional type のな基礎道具を使って作られた便利な組み込み型がある
- TSの型のすごいところ博覧会
- `ReadOnly<T>`
- T のすべてのプロパティを readonly にする
- `Partial<T>`
- T のすべてのプロパティをオプショナルにする
- `Pick<T, K>`
- オブジェクト型Tのプロパティの中から、その型がKの部分型であるものだけ抽出する
- `Omit<T, K>`
- Kで指定されたプロパティを除いたオブジェクトを返す
- `Extract<T, K>`
- `Exclude<T, K>`
- たしかに、こうやってみると、型上の演算があってよかったきもちになる
- ある型から別の型を作りたいという要請は結構ありそう
- それをtsはサポートしてくれる
# 7. TypeScriptのモジュールシステム
- 1つのモジュール(1つのファイル)をカプセル化の単位とみなすような書き方もできる
- あるモジュールAを別のモジュールB,Cから利用する
- この時のモジュールAの実体は1つ(利用先で共有される)
- モジュールA内で定義された変数を共有する
- mutableだと追跡大変そう
- あんまりなれない使い方だ
- default import/export
- 通常の named import/export の特殊な形
- 1つのモジュールに1つだけ存在できる、default という名前で import/export できる対象
- default と named 自体は共存できる
- IDEの補完が弱いので使わない方がいいという意見が一定の支持を集めている
- 同じ理由で import 時の as もそうらしい
- つまり export 側で名前を適切に決めるべき
- 一括import
- `import * as ${名前} from "./hoge.js"` みたいな感じでできる
- hoge.js の中身を現在のモジュール内に開くのではなく、`${名前}.var` みたいな感じで参照できるようになる
- 名前空間をオブジェクトによって実現してるのか
- 一括export
- まぁ同様な感じで
- スクリプトとモジュール
- 並列概念かつMECE?
- ts,jsのファイルはこのどちらか一方になる
- import/export はモジュールでのみ使用可能
- モジュールになる条件?
- html から script 要素で読み込まれる時に type="module" 属性を付与された場合
- node.js の場合は package.json の "type" フィールドに "module" という設定があるか
- でも .mjs ファイルは問答無用で module
- ts においては import/export が書いてあったら module になるのであまり気にすることはない
- スコープ
- スクリプトにするとその中のトップレベルの定義がプロジェクト全体から見えちゃう
- できるだけモジュールを使った方がいい by 著者
- 同意
- モジュール判定にするために `export {}` と空の export 文を書くらしい
- ES Module
- ここで解説したのは ECMAScript Module (ESModules, ESM) と呼ばれる
- ECMAScript 仕様書で定義されたものだから
- でもこれは 2015 年でやっとサポートされた
- その前からファイル間連携の需要はあった
- 非公式のモジュールシステムが作られた
- Node.js の CommonJS が広く知られる
- TypeScript はコンパイラオプションでいくつかの module system に対応可能
- CommonJSモジュール
- ES Module普及前から存在するモジュールシステム
- こっちがつかわれているのをみることもある
- `require('hoge')`関数で import する
- npm
- Node.js向けのパッケージマネージャ
- 代替ツールは yarn, pnpm
- デフォルトでパッケージのインストール先はプロジェクト内の node_modules/ 内
- DefinitelyTyped と @types
- ts から js で書かれたパッケージを利用しようとするとエラーになることがある
- 型定義がないので ts コンパイラがチェックできない
- これに型情報を付与するのが型定義
- `@types/${package_name}` として公開されている場合があり、あればそれを使える
- これは元のパッケージ作者と別の人が作っていることが多い
- この @types パッケージの開発、運用をしているのが、Microsoft が運営する DefinitelyTyped というシステム
- 自分で型定義ファイルを作る場合
- .d.ts ファイルに書く
- この接尾辞を持つファイルは ts コンパイラから型定義ファイルとして扱われる
- 型定義だけでそれ以外の実装は含んではいけない
- declare module 構文で定義する
```ts
declare module "express" {
const result: number,
export default result,
}
```
# 8. 非同期処理
- Promiseオブジェクトを返す非同期関数
- 基本的には、これが返された時点で(.then を呼び出す前から)非同期処理が開始している
- ここのthenとかchatch周りの意味論がむずい
- 例えば以下の例はよくない
- p の処理が失敗すると
- p2 の方でハンドリングしてないから、uncaught exeption 的な実行時例外が出る
```ts
import { readFile } from "fs/promise";
const p = readFile("foo.txt", "utf8");
const p2 = p.then((result) => console.log("成功", result); )
const p3 = p.catch((error) => console.log("失敗", error); )
```
- こうしないといけない
```ts
const p = readFile("foo.txt", "utf8");
const p2 = p.then((result) => console.log("成功", result); )
const p3 = p2.catch((error) => console.log("失敗", error); ) // p2 にチェーン
```
- 直観
- 前提
- .then や .catch で新しくプロセスを作るが、この時 p1.then(...) (= p2) として、p1 -> p2 のパスを想起する
- 最初の例では、p から p2, p3 に別々のパスができる
- このとき
- あるプロミスの中の計算が失敗したとき、そこからたどれる任意のパスで catch がされないといけない
- async/awayt
- Promise をベースにした、より便利な構文
- こっちの方が使われている
# 9. TypeScriptのコンパイラオプション
- tsconfig.json という名前にしておくと自動で設定ファイルだと解釈してくれる
- package.json とならんで主要な設定ファイル
- tsのプロジェクト環境構築
- 1. npm init で package.json を生成
- 2. npm install -D typescript で TypeScript コンパイラを node_modules 内に install する
- 3. npx tsc --init で tsconfig.json を生成
- node_modules の中に install した tsc コマンドが使われる
- include/exclude パラメータ
- コンパイル対象を指定する
- exclude は include の中にあり、かつこれで指定されたものを除外するという意味なので注意
- 静的検査の厳格さに関わるオプション
- strict
- 推奨
- 複数のオプションのエイリアスになっている
- 例
- noImplicitAny
- noImplicitThis
- ...
- noUncheckedIndexedAccess
- 厳しいけど入れるべき
- インデックスシグネチャを使った危険なコードを保守的に扱えるようになる
- これから作るならできるだけ厳しくする方がいい
- あとから厳しくするのは難しい
- 厳しい方がプログラマが安全に気を使うべき場所がへって楽になる