この記事は、昨日公開された以下の記事に対するアンサー記事です。TypeScriptで型定義に凝る派筆頭(自称)として、このお題に対して別の視点から光を当ててあげるためにこの記事を用意しました。
まず最初に、この記事(以下では元記事と呼びます)の著者を攻撃したり、元記事の内容を否定する意図はないことをご理解ください。結局のところ、考え方が異なり、前提が異なるから異なる結論になっているだけなのです。TypeScriptを使う皆さんがいろいろな観点から見た情報を取得し、自分の状況に応じた適切な考え方・判断をできるようにすることがこの記事の目的です。
要約
- 大きなコードを小さく分解しても本質的な難しさが消えるわけではないよ?
- 型はドキュメントなんだから正確に書こうぜ!
- 外界との接続も妥協せずに型システムで解決しようぜ!
- 機械にできる仕事を人間がするな!
TypeScriptによる型定義は単なるLinterでしかない?
では、元記事の内容を見ていきましょう。
元記事で最初に目立つのは次に引用する主張です。
まず、TypeScript による型定義は単なる Linter でしかない。コードが実行される時は基本的に JavaScript に変換され、TypeScript の構文で記した型定義は消失する。コーディング中に静的解析し、警告を出してくれるモノでしかないのだ。
これは「TypeScriptでは型定義はランタイムの挙動に影響を与えない」というTypeScriptの大原則を説明しているものと解釈できます。TypeScriptからJavaScriptへの変換では、元記事に書かれているとおりTypeScriptの型定義の部分は単に消失します。つまり、型定義のみが異なる2つのTypeScriptコードがあったとすると、それらのランタイムの挙動は同じになります。しっかりと型定義をしようがany
で誤魔化そうが、ランタイムの実行結果は変わりません。これは意外と珍しい特徴です。静的型付け言語では、型定義がコード生成のヒントとして使われることのほうが多いでしょう。
このことは、TypeScriptに付属のコンパイラ以外の手段でTypeScriptをJavaScriptに変換することを可能にしています。具体的にはBabelやesbuildなどです。型チェックは非常に複雑でヒューリスティクスも多いのでTypeScriptの専売特許となっていますが、変換部分だけなら他のツールでも実装できるのです。
以上のことを踏まえれば、TypeScriptの本質的な機能は静的解析であるという指摘は正しく、linterと呼ぶのも理に適っています。
ただし、これは機能面の話です。型定義にはもう一つ、ドキュメントとして機能するという重要な役割があります。これは元記事から欠けている視点であるように思います。元記事には少し後に事前条件・事後条件といった言葉が出てきますが、それらをうまく扱うにはどうすればよいのかというのはプログラム設計あるいは言語機能における大きなテーマの一つです。型定義はそれらについて、型システムでカバーできる範囲においては素晴らしい開発体験を伴う標準的な方法を提供してくれています。
分かりやすく言えば、関数の中身を見なくても引数と返り値の型だけ見れば関数の入力と出力が分かるということは、開発効率の上で重要だということです。
さらに、関数のインターフェース(入出力)が型定義の形で可視化されることは、リファクタリングやそのレビューの際にも役に立ちます。リファクタリングをする際には、処理が関数の外に出されたり別の関数の中に入れられたりすることがあるでしょう。そのような変化は関数のインターフェースの変化として現れます。インターフェースが複雑化してしまった場合はリファクタリングの方向性がおかしいことのシグナルとなります。もちろんコードの内容を読んで評価することも大事ですが、リファクタリング結果の可視化ができる手段が増えるのはよいことです。
型のロジック
インターフェースに関連して、ちょっと余談を挟みます。
TypeScriptの特徴として、型のロジックを記述する能力が際立って高いことが挙げられます。Mapped typesやconditional typesなど、TypeScriptの最も難しい機能群として知られるものがこの能力に大きく貢献しています。
型のロジックとは、簡単に言えばインターフェース記述において型の計算を行うことです。非常に簡単な型のロジックは、TypeScriptにおいて普通に現れます。たとえば次のサンプルコードを考えてみてください。
function repeat<T>(element: T, count: number): T[] {
const result: T[] = [];
for (let i = 0; i < count; i++) {
result.push(element);
}
return result;
}
インターフェースに注目すると、引数のひとつがT
で返り値がT[]
です。これは、repeat
関数は「T
型の値をT[]
型の値に変換する関数」であるという意味ですね。返り値に書かれているT[]
というのは、非常に簡単ながら「T
という型からその配列の型T[]
を作る」というロジックが書かれていると読めます。
この程度ではロジックとは言いにくいですが、TypeScriptで最低n個の要素を持った配列の型を宣言する方法で紹介したテクニックを応用してrepeat
の型定義を次のように改良したらどうでしょうか。次の例では、repeat
の返り値はタプル型となり、渡された数値に応じて返り値のタプル型の要素数が変わるようになっています。TypeScript 4.1で追加されたnoUncheckedIndexedAccess
と併用すれば便利な場面があるかもしれません(逆に言えば、noUncheckedIndexedAccess
に興味がない方にとってはこのような型定義はメリットがあまり無いかもしれません)。
type ArrayOfLength<N extends number, T> = ArrayOfLengthRec<N, T, []>;
type ArrayOfLengthRec<Num, Elm, T extends unknown[]> = T["length"] extends Num ? T : ArrayOfLengthRec<Num, Elm, [Elm, ...T]>
function repeat<T, N extends number>(element: T, count: N): ArrayOfLength<N, T> {
const result: T[] = [];
for (let i = 0; i < count; i++) {
result.push(element);
}
return result as any;
}
// [number, number, number] 型
const triple = repeat(123, 3);
// [string, string] 型
const tuple = repeat("pika", 2);
ここで定義したArrayOfLength
型は、型レベル再帰を行なっておりロジックという感じが出ていますね。慣れていない方には読みにくく感じるかもしれませんが、repeat
関数の返り値がT[]
に比べてより具体的になっており、ドキュメンテーションの精度という点では明らかに改善されています。
関数に複雑な型定義がつく場合、ほとんどのケースではこのように「より正確なインターフェースを提供すること」が目的となります。ArrayOfLength
は極端な例ですが、より身近なものとしてはstring
型の代わりにより具体的なリテラル型を返したりするのもその一環でしょう。
インターフェースの正確性が低い場所があると、その部分に引きずられてプログラムの全体の型定義の正確性が低下して、結果として安全性もそれに合わせて低下します。なるべく正確なインターフェースを書き、または必要に応じてインターフェースの正確性を向上させることはプログラムの読みやすさ・安全性の両面で重要なのです。
例えば、TypeScriptプログラムを書いていると「ここでこの値はundefined
である可能性はないのに型にundefined
が残っている」というようなケースに遭遇することがあるでしょう。これは、結構インターフェースの改善によって対処できることがあります。
複雑な型定義を読まないといけないので分かりにくいという意見があるかもしれませんが、この辺りの話はすでにこわくないTypeScript〜Mapped TypeもConditional Typeも使いこなせ〜で取り上げているので詳しくはそちらをご参照ください。要約すると、プログラムのロジックを読み解くことはプログラミングにおいてそもそも欠かせない技能であり、それが型のロジックだからといって特別に忌避する理由にはならないはずです。読み解く助けとするために型のロジックを小さく分割したり、コメントを補ったりするほうが懸命です。上のような型定義によるインターフェースの改善が有用だと感じたならば、怖がらずに型のロジックを書いていきましょう。
複雑な型定義はいつ必要なのか?
次の話題として、元記事の以下の部分を取り上げます。
こういう API がある、こういうレスポンスが来る、じゃあコレを TypeScript が持つ構文でどうやって表現しようか、あぁ新しい TS じゃないとこういう型定義は難しいな、Interface を用意してー、クラスを用意してー、入れ子になっているからー…。
なんというか、そもそもそういう**複雑な型の考慮が必要になっているシステム設計が悪いんじゃないか?**それ。
一つのプロジェクトを小さく作っておき、外部通信が最小限で済めば、レスポンスを型定義したい場合も、簡単なクラスを何個か定義するだけで良い。使うプロパティのことだけ考えれば良いだろう。
オブジェクトや配列をこねこねする関数に関しても、一つの関数を小さく作れば、引数と戻り値はほとんどがプリミティブ型で扱えるレベルに落とし込めるはずだ。引数の表現に複雑な型定義が必要な関数は、その関数の設計自体が何らか誤っている。もっと小さく分割してやろう。
この主張を言葉通りに受け取ると、残念ながら筆者としてはあまり賛成できません。
ここでの主張は「良いシステム設計とすることで複雑な型定義の必要性を減らすことができる」ということでしょう。しかし、「システム設計」は型定義の複雑さとはあまり関係ないというのが筆者の考えです。
システムには求められる要件があります。その要件は何らかのロジックによって実装されます。ロジックは、必ず複雑性が伴います。そのロジックを実装する以上、どれだけ関数を分割しようと本質的な難しさは変わらないはずです。変わるのは、インターフェースの良さやテストのしやすさといった、機能以外の部分です。
先ほども述べたとおり、関数分割の設計を良くすることで複雑な型の必要性を減らせる可能性はあります。その点で、引用部分後半の「引数の表現に複雑な型定義が必要な関数は、その関数の設計自体が何らか誤っている」という部分はある程度賛成できます。一方で、「オブジェクトや配列をこねこねする関数」に関しては、それをさらに分割してもオブジェクトや配列をこねこねする処理がどこかに消えて妖精さんがやってくれるわけではありません。どこかに必ずオブジェクトや配列をこねこねする処理が残っているはずです。プログラマがその部分を意識しなくなったとしたら可能性は2つあります。一つはTypeScriptの型推論により我々が意識するまでもなくうまく型安全性が保たれているか、あるいはTypeScriptが許してくれる危険性に甘えて本来存在する複雑さをコードの表面に現れないように隠蔽したかのどちらかです。筆者の考えとしては、オブジェクトや配列をこねこねする(特にmutationが伴うような)処理はTypeScriptのサポートが及びにくく危険性が発生しやすい部分ですから、多少型定義を複雑にしてでも危険性による汚染範囲を狭い関数内にとどめるべきだと考えています。
つまり、確かに複雑な要件を単純なものに分解するのは良いことですし、それをすれば簡単なインターフェースの関数は量産されるでしょう。しかし、それは複雑な要件が魔法のように消え去るわけではなく、プログラムが全て簡単なインターフェースの関数で満たされるとは考えにくいのです。プログラムに複雑な要件があればあるほど、必然的に複雑なインターフェースの関数が必要な場面は増えるでしょう。
「一つのプロジェクトを小さく作っておき〜」という主張に関しても、機能が小さいプロジェクトならば複雑な要件がその中に入り込む可能性が減るので、それはそうです。しかし、プロジェクトを小さく分割したからといって、複雑な処理をしなければならない部分がクラウドの向こうへ忽然と消えてしまうことは無いでしょう。ただ単に、複雑な部分がある小さなプロジェクトに押し込まれただけです(もちろん、それは悪いことではありません。ただ単に、TypeScriptの複雑な型定義を否定する理由にはなっていないというだけのことです)。
ですから、良くすべきはシステム設計ではなく関数設計であり、しかも、「型定義の簡潔さ」と「安全性」はまた別の指標であると筆者は考えています。型定義の簡潔さは最大限の安全性を意味しません。安全性を最も重視するならば、型定義の簡潔さを捨てなければならない場面もあるでしょう。
逆に、安全性を最重要なミッションとせずカジュアルにTypeScriptを使いたいならば、それは型定義の簡潔さを優先する理由たりえるでしょう。ここでもやはり、TypeScriptに何を求めるかによって取るべき道が変わってくるのです。
ドメインロジックと型の複雑さ
正直なところ、元記事の筆者がどの程度の機能を「複雑な型」と呼んでいるのか筆者にははかりかねています。特に、「APIのレスポンス」に言及するということは、ドメインロジック的な部分の型定義が考慮に入れられていると思われますね。
ドメインロジックをTypeScriptで記述するのにあたって特に有用なのはリテラル型とユニオン型です。両者を組み合わせることによって、「または」の表現が型安全な形で可能になります。いわゆる関数型言語の最も優れた機能の一つとしてADT (Algebraic Data Types) が受け入れられていることからも分かるように、「または」という概念を記述できることはロジックの記述において非常に有用です。
簡単な例としては、いわゆるOptionやEither、Resultなどに代表される「または」を表すデータ構造は、(特に例外の型上のサポートが必然的に貧弱な)TypeScriptにおいてはとても重要なものです。例えばエラー処理においても、個人的にはRustにおけるResultとpanicの使い分けのような形でTypeScriptでもデータ構造と例外を使い分けるのが理想に近い形なのではないかと思っています。
ユニオン型を持たない言語の場合は、インターフェースとか継承とかを用いてより間接的な(筆者の考えでは、分かりにくく無駄の多い)方法で「または」を記述することになります。特に、TypeScriptはクラスではない生のオブジェクトに対する型上のサポートが強力であるゆえに、クラスという余計なレイヤーを噛ませずにさまざまなデータを表現することができ、ユニオン型による「または」の表現もまさにその一つです。
元記事に「簡単なクラスを何個か定義するだけで良い」という表現があることから察せられるのは、筆者はこのようなTypeScript的文化に馴染みがないか、あるいはあえて距離を取っているかということです。TypeScriptにおいては何でもクラスに頼るのは必ずしもスタンダードではないということは、色々な視点から検討する意思のある読者ならばぜひ理解しておいていただきたい点です。
外界との接続について
プロパティの存在チェックは TypeScript の型情報だけで済む話ではなく、事前条件・事後条件として検査し、異常時はエラーハンドリングすべき事項だろう。API コールが絡むと、型が保証される場面は少ないと考えている。あまり他システムというモノを信じていないのだろう。
「APIコールが絡むと、型が保証される場面は少ない」。全くもってその通りですね。我々がTypeScriptで書いたプログラムの外からやってくる入力は、全て信頼できないものです。型で言えばunknown
です。APIに関しては型定義を生成することもできますが、生成された型定義がランタイムに100%絶対に正しいのか保証するのは難しいでしょう。サーバーのAPI側のバージョンが変わったらどうなるのかとか、考えることはたくさんあります。
これに関して言えるのは2つです。
- 外界からの入力をどれくらい信頼し、その結果としてどれくらい安全性を削るのかはあなたが決めることです。
- 外界からの入力を信頼したくないけど型安全性を担保したい場合はio-tsのようなランタイム型チェッカを使えばできるので使ってください。
特に2つ目のポイントは重要です。元記事によれば、このような信頼できない入力は「事前条件・事後条件として検査し、異常時はエラーハンドリングすべき」です。これはその通りです。しかし、だからTypeScriptは無意味である(TypeScriptの型システムに頼らずに自前で検査する必要がある)というのは早計です。
先ほど紹介したようなランタイム型チェッカは、型に関するsource of truthをひとつにできるのがポイントです。TypeScriptでは型定義からランタイムの挙動を生成することはできませんが、逆にランタイムのコードから型定義を生成することはtypeof
型のおかげで可能なのです。io-tsのようなものを使うためには「ランタイムの型表現」を用意し、これをsource of truthとして、型システム上の型定義を生成します。このテクニックによって、「ランタイムの挙動(io-tsなどによる事前条件の検査)」と「型システム上の保証」が単一のsource of truthから生成されて一致することが保証されます。
つまり、TypeScript的な枠組みに乗り、TypeScriptの型システムを最大限活かしたまま、「プロパティの存在チェック」のような異常検知を行うことは可能なのです。敢えてTypeScriptの型システムのメリットを捨てる理由はありません。必要ならば使ってください。
意図しない型変換を防ぐことについて
JS 初心者がつまづくのは
- Truthy・Falsy という概念 (全てが Boolean でみなせる)
- String と Number の取り違え
- null と undefined の存在
くらいだろう。これら3つは簡単に覚えられる。これらの避け方を知っていれば、意図しない型変換による障害など防げるのだから、JS を普通に書ける自分個人は、ほとんど TS がなくても困らないと思っている。
筆者は、人がわざわざ気をつけなくてもいいように、機械に任せられるものは機械に任せたいです。意図しない型変換を防ぐことは、TypeScriptがやってくれることの代表例です。
尤も、ここは意見が分かれるところかもしれませんね。機械よりも人間の温かみが大事だと感じられる場合は、頑張って気をつけましょう。
ただ、TypeScriptのオピニオン(何を防いで何を防がないか)とあなたが求めるものが一致しないことは考えられます。基準をTypeScriptよりも緩くしたい場合は、any
などを活用したラッパーを作っていい感じにしましょう。基準をTypeScriptよりも厳しくしたい場合は、typescript-eslintを活用するのがお勧めです。既存のソリューションでは解決しない場合は、提案したり議論したり実装したりするのがよいでしょう。
明示的な型変換について
- Falsy なモノを暗記。それ以外は Truthy。これらは曖昧等価比較により if や三項演算子で true・false とみなされ比較できる
- 基本は面倒でも厳密等価比較を書くだけで、TS の型定義なしに比較がちゃんとできる
- 必ず String 文字列にしたければ、次のようにテンプレートリテラルで再定義すれば良い。古くは + '' と空文字を繋げることで文字列にしていたモノだ
- 必ず Number 数値にしたければ、Number() コンストラクタに入れて Number.isNaN() で判定すれば良い。数値じゃないと困る時は何らかエラーハンドリングが必要だろう
- null と undefined だけは曖昧等価比較を使って良い場所 (ESLint などでもそのようなルールがある)。次のように書けば null と undefined をまとめて除外できる。これらに意味付けして null と undefined を頑張って区別しようとするとつらいので、どちらもいっしょくたに扱い、「空である」ことは他の方法で表現した方が良い
たいへん申し訳ないことにどのような主張のためにこの記述があるのかうまく理解できていないのですが、これらのテクニックは普通にTypeScriptでも使えるので使えばよいと思います。それよりも、筆者としてはこの部分を「与えられたものをstring
やnumber
に変換すればいいからTypeScriptで引数をstring
やnumber
にアノテーションする必要はない」という主張に読み取りました。違ったらすみません。
これに関しては、2つの観点から筆者としては同意できないと感じます。
まず、このようなことを行うと、関数のインターフェースとしての意味が破綻しがちです。どんな値でも引数として受け取るということは、TypeScriptで言えば引数がunknown
型であると宣言するということです。つまり、関数の事前条件を何も宣言しないということです。言い換えれば、こちらで変換を受け持つから何を渡してくれてもいいよということです。
そして、このような宣言はプログラムの書いた人の想像を容易に超えます。元記事には`${variable}`
のようなコードで与えられたものを文字列に変換します。しかし、もしvariable
にオブジェクトが渡されたら文字列は高い確率で"[object Object]"
となるでしょう。それは意図した結果ですか? 意図した結果でないならどう対応しますか?
対応として「型定義には頼らないけど使う側で気をつける」はあまりに馬鹿げています。型定義に頼りましょう。例えば数値と文字列しか受け取りたくないのなら型定義にstring | number
と書くことになるでしょう。しかし、そこまでするならなぜstring
型のみに制限しないのですか? 渡す側でnumber
をstring
に変換すべきではないですか? 関数は小さい方が良いのではありませんでしたか? 外から来るデータだから型が何か分からないですか? しかしその問題は前節で解決したばかりではないですか?
もう一つの問題として、TypeScriptの力で無くせる無駄な型変換をわざわざ行うのは富豪的にすぎるという点が挙げられます。JavaScriptの著名なユーティリティライブラリであるlodashは、色々な入力に対応できるように様々なチェックや変換を行なっており、元記事の筆者の考え方に適合しています。しかし、今少なくともフロントエンド開発ではlodashは衰退しようとしています。少なくとも、有識者の間でlodashの使用を積極的に推進する声は聞かれません。それはなぜかと言えば、富豪的すぎでコード量が無駄に大きいからです。
フロントエンド開発ではコード量を削減することは非常に重要視されており、TypeScriptの静的解析で解決できるものをわざわざランタイムで行うのはその問題意識に反しています(先ほど出てきたio-tsも型チェックをランタイムで行ないますが、あれは外界が信用できないという問題があるのでまた別の話です)。
ですから、コード量の削減に意味を見出す考え方の人ならば、TypeScriptの静的解析に頼るのが理に適った選択だと言えるでしょう。
環境構築の面倒くささについて
個人では TypeScript はほとんど使っていない。毎度 package.json に typescript が登場したり、tsconfig.json や .eslintrc.json での調整が必要だったりするのが、クソ面倒臭いのだ。上述のようにハマりやすいポイントは JS オンリーで簡単に避けられるので、サッサと直接 node コマンドで実行できる、ないしはブラウザで直接実行できるように、素の JS で書いてしまう。TypeScript は IDE (というか VSCode) での補助機能を当てにした連携も多く、既存の巨大なコードを直す場合はそうした恩恵に預かれるのは良いが、そもそもそんな巨大なコードを書くなということである。
これも難しい問題ですね。環境構築により失われる時間とTypeScriptの導入により削減される時間を天秤にかけましょう。巨大なコードを書かないならばTypeScriptを導入しないという選択が合理的な場合もあるでしょう。ただし、先にも述べたとおり、巨大なプロジェクトを小さなプロジェクトに分解しても本質的に何かが変わるわけではないという点は留意してください。
まとめ
この記事では、TypeScript の型定義に凝りすぎじゃね?という記事を取り上げてその内容に対する筆者の見解を述べました。
記事中でなんども強調しているように、最適な選択というのは各々の目的や考え方(安全性をどれだけ重視するかなど)によって変わります。色々な意見を参考にして、自分の状況に合った選択ができるようになることが何よりも重要です。
筆者はかなり安全性を重視する側に寄った意見を発信しています。安全性に興味がある方は、筆者の他の記事も参考になるかもしれません。