Effective TypeScript
1. TS と JS の関係を知る
- TS は JS のスーパーセット。JS のコードは TS のコードとして使える。
- TS は静的型付けにより、ランタイムエラーを事前に検出することを目的としている。
- 型チェックをパスしてもランタイムエラーは起きうる
- 引数の数が違うなどの明らかにおかしい構文は、JS ではチェックされないけど TS ではチェックされるものがある
- 型注釈は、開発者の意図を明示し、コンパイラが正解と不正解を見分けやすくするために使うもの
2. TypeScript のオプションを知る
- TS はオプション設定で劇的に動作が変わる
- JS からの移行プロジェクトを除き、
noImplicitAny
は必ずオン strictNullChecks
もundefined is not an object
のエラーをなくすために大事strict
はそれら厳しめの設定の盛り合わせであり、オンを推奨strict
よりも厳格なnoUncheckedIndexedAccess
のようなオプションもあるので適宜選択する
3. コード生成と型は独立していることを知る
- インターフェース、型、型注釈はトランスパイル時に全て削除される。
- このためランタイム時に
instanceof SomeType
みないなことはできない。ランタイム時の判定には「タグ付きユニオン」などを使え。 - また、型がランタイムのパフォーマンスに影響を与えることはない。ビルドタイムには影響を与えるけどね。
- このためランタイム時に
- class のように型と値を両方生成するものがある。
class Foo {}
class Bar {}
// 型として利用
type FooOrBar = Foo | Bar
// 値として利用
if (a instanceof Foo) {
}
- 型エラーはあってもトランスパイルはできる。
- 少々型エラーがあっても動かせた方が開発時には便利だよね。
noEmitOnError
を使うと、型エラーがあるとトランスパイルしないようにできるけど。
- 型アサーション(キャスト、
as Hoge
)しても実際の値は変わらない。 - ランタイムの値は型と相違しうる。だからこそ、unsound な値(any、型アサーション、構造的部分型のミスユース)はなくすのが大事。
- 関数を型レベルでオーバーロード定義することはできるが、実装は一つしか書けない(どゆこと)。
4. 構造的部分型と仲良くなる
JS は Duck Typed であり、TS はそのモデリングとして構造的部分型を採用している。 余計なプロパティを持っている可能性があるということ。 TS の型システムは closed/sealed/precise ではなく、open である。
構造的部分型をうまく使うと、テスト時に全てをモックすることなく、必要最低限の部分だけをモックすることができたりする。
5. any を使わない
any はあらゆる厄災のもと。使うな。
- 全ての型チェックが無効化される
- 型安全性が失われる
- DX が悪化する
- リファクタリング時の事故を誘発する
- あなたの型設計を覆い隠す
- 型システムへの自信を喪失させる
6. エディタ上で型を使い倒す
- TypeScript Language Service はエディタと協調して動作する
- 型がどのように動作しているかを確認できる
- 変数名や名前の変更といった、TypeScript のリファクタリングツールと親しくなろう
7. 型は値の集合であると考える
型は、値の集合である。この集合のことを、その型のドメインと呼ぶ。
集合には有限集合(e.g. boolean, リテラル型)と無限集合(e.g. string, number)がある。
型は厳格な階層構造ではなく、ベン図のように交差集合を形成する。 サブセットではないものの共通する部分はある、という状態がありえる。
型のオペレーションは集合(ドメイン、ベン図)に対して行われる。
A | B
はドメイン A とドメイン B の和集合である。
A & B
はドメイン A とドメイン B の積集合である。
オブジェクトの場合、余計なプロパティがあったとしても型に属せる。
直感とは異なるかもしれないが、構造的部分型の考え方に基づいている。
そして前述の通り、型のオペレーションは(オブジェクトのプロパティではなく)集合に対して行われるため、
オブジェクトA & オブジェクトB
は、オブジェクト A とオブジェクト B の両方のプロパティを全て持つオブジェクトを表す。
extends
、is assignable
、is subtype of
は、すべてis subset of
の言い換えである。
例えば'a' extends string
は真である。
ある型のドメインが別の型のドメイン内包するとき、代入ができる。
型 B を型 A にアサインできません、ということは、 型 B のドメインが型 A のドメインのサブセットでないということを表す。 または型 B の値が型 A のドメインのメンバーでないことを表す。
- 最も小さな集合は never 型
- 空の集合である
- あらゆる型のベースとなるため、Bottom Type と呼ばれる
- 次に小さな集合はリテラル型
- 一つの値だけを含む有限集合である
- number 型は全ての数値を表す無限集合
42
は含まれるが、"Hello"
は含まれない
- string 型は全ての文字列を表す無限集合
42
は含まれないが、"Hello"
は含まれる
- 最も大きな集合は unknown 型
- あらゆる値を含む無限集合である
- never の対極にあり、Top Type と呼ばれる
- どんな値でも代入できる
8. シンボルが型空間にあるのか値空間にあるのかを知る
TypScript のシンボルは、型空間に存在するものと値空間に存在するものがある。
あらゆる値は型を持つが、これは型空間でのみアクセス可能である。 type や interface は全て消去され、値空間ではアクセス不可能である。 TypeScript Playground にぶっ込んで生成コードを見るとわかりやすい。
class や enum など、型と値を両方生成する要素もある。
typeof
やthis
は、型空間と値空間で別の意味を持つ。
9. 型アサーションより型注釈を使え
型アサーションではなく、型注釈を使え。
関数の返値の型を注釈すると、安全なうえに結果に型が付いて便利。 特にマップするときとかね。
type Person = { name: string }
const people = ['Alice', 'Bob', 'Charlie'].map((name): Person => ({ name }))
型アサーションや non null assertion を使っていいのは、 DOM 操作時など、TS よりも人間がコンテキストを知っているという特殊な状況だけ。 このときは、なぜアサーションの利用が正当化されるのか、コメントをつけておくとよい。
型アサーションをキャストと呼ぶな。他の言語と違って、値は変わらないから。
as const
は型アサーションじゃなくて、const context
であり、安全なものである。
値の型を「最も狭い型 + すべて readonly」にするおまじない。
10. Object Wrapper Types を使うな
String
,Number
,Boolean
,Synbol
,BigInt
は Object Wrapper Types と呼ばれる。
new String(hoge)
のようにすると直接扱うこともできるが、使うな。
もともと、TS ではプリミティブな値はメソッドを持たない。
"Hello".toUpperCase()
は、"Hello"
がString
型という
Object Wrapper Types に一時的に変換されることで利用可能になっている。
なお、new しないString(hoge)
などは単にプリミティブ型にキャストするだけなので使ってもよい。
11. 過剰プロパティチェックと型チェックを区別せよ
厳密な Structural Typing だと、その仕組み上、タイポや凡ミスがスルーされがちで不便である。 このため、過剰プロパティチェックという仕組みが用意されている。
これは型チェックとは違う仕組みなので注意すること。 (TypeScript は Closed な型システムではないことを思い出して)
オブジェクトリテラルを使って、型がわかっている変数に代入したときや、関数の引数に与えたときに発動する。 中間変数を使うと、過剰プロパティチェックは発動しない。
type Person = { name: string; gender?: string }
const person: Person = { name: 'Alice', age: 42 } // エラーになる
const intermidiate = { name: 'Alice', age: 42 }
const person2: Person = intermidiate // エラーにならない
オプショナルなプロパティしか持たない型を Weak type と呼ぶ。この型への代入時には、 (過剰プロパティチェックとは別に)少なくとも一つの一致するプロパティがあるかチェックが行われる。
12. なるべく関数自体に型注釈をつけよ
Statement ではなく Expression で関数を書き、型注釈をつける。 そうすれば、引数と返り値の型を個別に書かなくて済む。
同じシグネチャの関数を複数書くときは、関数自体の型を定義して使いまわす。
もしくはすでに定義されている型を使う(MouseEventHandeler
とかね)。
ライブラリを作るときは、よく使うコールバックの型をエクスポートしておくと親切だ。
既存関数の型を使いたいときはtypeof fn
を使う。
さらに戻り値の値を上書きしたいならParameters
と rest parameter を使う。
const myFetch = (...args: Parameters<typeof fetch>): Promise<number> => {}
13. type と interface の違いを知る
- interface
- merging が使える
- 常に名前で表示されわかりやすい
- type
- union が使える
- 条件型が使える
- inlining されると型名が消える
どっちを使ってもいいが、ルールが未決定のプロジェクトであれば、 なるべく interface を使うのがおすすめとのこと。
(個人的には interface を使うメリットを感じないので、シンプルに type に統一するのがいいと思う)
ちなみに型の頭に I や T などをつけるのはバッドプラクティスである。
14. readonly を使う
JS では、プリミティブな値は元から immutable だが、オブジェクトや配列は mutable である。
関数が値を変更しないなら、配列ならreadonly T[]
、オブジェクトならReadonly<T>
で受けとろう。
そうすれば、うっかり変更してしまうことを防げる。
const は再代入を防ぎ、readonly は変更を防ぐという点で異なる。
readonly は shallow に適用される点に注意する。 Deep に適用したいなら自作せずにライブラリを使え。 また、メソッドプロパティを介した変更は依然として可能である点にも注意する。
15. 型オペレーションとジェネリック型を使って繰り返しを減らす
DRY 原則は型の世界でも適用される。
型に名前をつけて繰り返しを減らそう。 interface で extends して繰り返しを避けよう。
型を別の型にマップ(変換、写像)する公式の方法を知ろう。
keyof
, typeof
, indexing, mapped types(Record
) などがある。
typeof
は値の世界と型の世界の両方に存在するので注意すること。
mapped types(Record
) は[A in B]
のような書き方をする。
配列のループ処理を同じような動作をする。
特にA in keyof B
の様に書いたときはhomomorphicにマップされ、
readonlyやoptionalといった情報がそのまま引き継がれる。
// typeof
const config = { host: '', port: 0 }
type Config = typeof config // { host: string; port: number; }
// keyof
type Keys = keyof Config // 'host' | 'port'
// indexing
type Host = Config['host'] // string
// mapped types
type ReadonlyCfg = {
readonly [K in keyof Config]: Config[K] // { readonly host: string; readonly port: number; }
}
ジェネリック型は型の世界の関数である。
型を別の型にマップするために使う。
公式のPick
, Partial
, Record
, ReturnType
などを知っておこう。
// 関数定義 (左側に<>がある)
type Box<T> = { value: T }
// 関数適用 (右側に<>がある)
type StringBox = Box<string>
DRY 原則にこだわりすぎないこと。 間違った抽象化よりも繰り返しの方がマシ。
16. Index Signature は避けてもっと正確な型を使う
{[key: string]: string}
のような書き方をするのが Index Signature である。
Index Signature は、どんな値がやってくるか不明な動的データに限って使うもの。 anyのような悪い効果をもたらすので、なるべく避けるべき。
代わりに、interefaceを定義せよ。
もしくはRecord<Union, V>
/ Map<Union, V>
や、
最悪でも{[key: Union]: V}
を使え。
17. Numericな Index Signature は使うな
JSでは、あらゆるオブジェクトのキー名はstring or symbolである。
配列も例外ではない。 TypeScript で配列の要素取り出しにnumber を使える様に見えるのは、 バグを見つけやすくするための架空の仕組みに過ぎず、 あくまで見かけ上のフィクションである。
const arr = ['a', 'b', 'c']
/**
* 配列はこんなふうに管理されている
* { '0': 'a', '1': 'b', '2': 'c', length: 3 }
*/
arr[1] // 'b' <= 数値でキーを指定できるようにみえるが
Object.keys(arr) // ['0', '1', '2', 'length'] <= 実はキーは文字列
自前オブジェクトに numeric キーを付けると様々な厄災が起きるのでやめよう。
数値をキー名にしたくなったら、配列、タプル、ArrayLike
、Iterable
で事足りるはず。