角待ちは対空

おもむろガウェイン

がんばらないTypeScriptのはじめ方

このエントリは2017/07/12に行われたHatena Engineer Seminar #8 @ Tokyoの発表内容をブログ向けに書き直したものです。

事前の通知では「CoffeeScript脱出にみるTypeScript2.4時代のベストプラクティス」がタイトルだったのですが、主題を変えたためタイトルも「がんばらないTypeScriptの始め方」に変更させていただきました。CoffeeScript脱出の話は一応出てきます。

社内のTypeScript事情

社内でTSが使われ始めたのはTypeScript で実現する MVP アーキテクチャパターンの事例が初でした。2015年頃の話でTS1.4くらいの時代でした。

このプロジェクトはもともとJSで書かれていたものをTSに書き直したプロジェクトであり、その様子も上記のエントリで触れられています。また、TSの置き換えを完全に終了させたのが僕でした。

その後のTypeScript

その後の新規プロジェクトについては大体TSが採用されいるという状況です。ただし技術選定はある程度リードエンジニアに任せられるため、ES6で書いてBabelを使うという選択をしたプロジェクトもあります。またはてなブログではFlowが採用されました。この辺の経緯については当日パネルディスカッションで触れられています。

現在の様子

  • 社内ではフロントエンドのスタンダードと言っていい
  • が、全員がTSをキャッチアップしている状態ではない

という状況です。

TSはJSが書ければなんとなく書けてしまいますし、またTS自体のアップデートが早いため、メンバーの習熟度とTSの機能にどうしてもギャップが出てきてしまいます。ではそれをどうやって埋めていけばいいか、どのようにTSと付き合っていけばいいかという課題に対する答えが今回の発表の主題でした。

TypeScriptのがんばらないはじめ方

  • 型付けを “がんばらない"話
  • 型定義ファイルを"がんばらない"話
  • TSへの移行を"がんばらない"話

の3セクションで話を展開していきます。

型付けを “がんばらない"話

がんばらなくても十分メリットあるという話です。

TSの大前提として、型関連の記法はコンパイル結果であるJSへ影響しません。どういうことかというと

interface O {
    str?: "abc" | "xyz"
}
let o: O = {str: "abc"}; console.log(o.str!.charAt(1));

のようなコードがあった時、interface、type annotation、type annotationあたりはTS独自の型関連のシステムでこれはコンパイル結果に影響しません。

上記のコードは

var o = { str: "abc" };
console.log(o.str.charAt(1));

へコンパイルされますが、例えば

let o = {str: "abc"};
console.log(o.str.charAt(1));

も同じ結果へコンパイルされます。

JSへ影響がない範囲ではゆるくやっていくのがいい、というのが僕の薦めるTSとの付き合い方です。

<any>を許容する

any型へのassertionである<any>もJSへの影響はありません。なのでTSを書いていてよく分からなかったら<any>してしまえばいいと思います。

振り返ってみれば我々は今までJSを書いていたわけで、部分的に<any>したとしてもJSを書くことになるだけで正しくコードを書けないということはありません。なので<any>を「ここはよくわからなかったので今からJS書きます」くらいの宣言に考えあまりネガティブにならないことをおすすめします。TSが分からなければJSとして正しいコードを書けば良いのです。

とはいえ、TSの型検査をクリアできないということは大体は間違っています。チーム内にちゃんと解決できる人がいるのであればレビューなどで指摘していけばいいですが、レビュワーもなんで型検査が通らないのかわからないのであれば、JSとしてレビューすればいいのです。TSへの理解はおいおい上げていけば十分です。JSを書いていたときより悪くなることはありません。

ゆるく導入して意味あるのか

それTS導入する意味あるのかという疑問が上がると思いますがあります。

<any>の場合であればなんか不味そうなことやってるなと見ればわかる分だけJSを生で書くよりリファクタリングしやすいです。JSの場合怪しいことやってる場合自分でコメント残すしかありませんが、<any>の場合システムで強制的に残せるというメリットがあります。

また例えばInterfaceやGenericsなどTSの独自の記法は書かず、JSとしてvalidな範囲で書いていたとしても、型推論が効くためかなりのメリットをもたらします。

一例としては

let array = [] // この時点ではany[]

class A {
    name: string
}
let a = new A;

class B {
    name: string
}
let b = new B;

array.push(a); // => この時点でA[]と判定される
array.push(b); // => この時点でA|B[]と判定される

array.map((v) => {
    v.name // => AとBに共通のプロパティならば呼べる
})

class C {
    value: number
}
let c = new C;

array.push(c); // => この時点でA|B|C[]と判定される

array.map((v) => {
    v.name // => vはA|B|CなのでAとBとCに共通のプロパティしか呼べずエラー
})

のようにJSとしてvalidな範囲のコードであってもエラーを防止できます。

以上のようにチームのTypeScript習熟度によってはBetter JS程度の運用をし、よくわからないエラーが出たらassertionでごまかすでも十分意味のある導入なので取りあえず導入し、漸次的に良くしていけばいいのです。

オプションの話題

ゆるい導入で議論すべきコンパイルオプションは2つあってnoImplicitAnystrictNullChecksです。

noImplicitAnyは暗黙的なanyを禁止するオプションですが、型推論の改善によって昔ほど有効にしておくモチベーションはないと思います。

有効にすべきかどうかの論点は

  • 仮引数のtype annotationつけ忘れが防げる
  • モジュールのimportのをする時型定義ファイルが必須になる

の2つだと思います。

前者については簡単で以下な関数でtypeのtype annotation忘れを防げます。

function hoge (type: "A" | "B") {
    return type
}

流石に仮引数に対して型推論はできないので、このannotationを怠ると以下は全部anyになってしまいます。なので極力annotationを入れたいです。noImplicitAnyが有効な場合、annotation忘れは指摘してくれます。

もう1つの論点はモジュールimportの際に型定義ファイルが必須になることをどう捉えるかです。Better JSくらいで使って最初は極力ハマりたくないのであれば無効にするべきでしょう。型定義ファイルについては後述します。

参考: TypeScript 2.1 · TypeScript 参考: Announcing TypeScript 2.1 | TypeScript

さてstrictNullChecksはTS2.0からの目玉機能で、nullチェックを強要するオプションです。

interface O { str: string | null };
function generateO():O {
    return {str: null}
}
function output (s:string) {
    console.log(s)
}
let o = generateO();
output(o.str)  // => error
output(o.str!) // => ok ただし単なるassertionなので実行時エラーの可能性がある
if (o.str !== null) {
    output(o.str) // => ok
}

以上のようにnullundefinedの場合のチェックが必須になります。

こちらはゆるくTSを使うと言っても必ず有効にしたいところです。何故ならばnullチェックはJS書いてても忘れることが多く、それが防げるだけでもかなりのメリットがあると思われるからです。またこのオプションに関しては後から有効にする難しい(というかだるい)ので最初から有効にしておき、いちいちif文相当のチェックをするのが面倒であるならばassertionで済ますという選択肢をおすすめします。

参考: TypeScript 2.0 · TypeScript

型定義ファイルを “がんばらない"話

少しがんばったくらいじゃ厳しいので諦めようという話です。

型定義ファイルはTSにおける最大の難所です。大体勘で書けるTSですが、型定義ファイルに関しては勘では無理だと思われます。

型定義ファイルは難しい

  • 書く側の難しさ

    • 独自のキーワードや世界観
    • 複数ある書き方
  • 使う側の難しさ

    • 歴史的経緯
    • 型定義ファイル自体が間違っている事が多い
    • 修正するには結構な知識がいる

型定義ファイルの難しさをまとめると以上でしょうか。型定義ファイル自体JSにはない概念なので、当然学習にコストがかかります。

その上で型定義ファイルの書き方は一意ではありません。ベストプラクティスは公式で用意されていますがそれに従って定義ファイルが書かれているとは限らないです。また過去には書けなかった書き方が現在のベストな書き方であることもあるので、今から入門した場合何故そうなっているのかの理由の特定が困難です。

参考: Introduction · TypeScript

自分からは積極的に書かないという姿勢でいたとしても、型定義ファイル自体が間違っていることというのは往々にしてあるので、それを修正するには書く側の知識が必要になります。またDeclaration Mergingなどの概念を知らないと難しいでしょう。

参考: Declaration Merging · TypeScript

という感じで色々難しさはありますが、一番初心者に厳しいのは「型定義ファイル自体が間違っている事が多い」ということです。何かしらのエラーが出た時、自分は間違っていない可能性があるため、正しい道に進むことが困難になります。とくにexportの仕方が間違っている場合、実行時(というかbrowserify)のエラーになるため、慣れないと正しい対処が大変困難です。その上正しい対処には結構な量の書く側の知識が必要です。

少し古いですが過去にTypeScript の型定義ファイルと仲良くなろうという記事を書きました。ちゃんとやるのであれば、これくらいの知識が最低限必要になるので、ハードルは高いと思います。

駄目なら捨てる

何も問題なく使えるのであればそのまま使うのが絶対に良いです。また多少返り値や引数の型が間違っている程度であれば、外部から修正するよりはassertionしてしまったほうがコストとメリットのバランスが取れると思います。もちろんPR送って大本を正しく修正するのが一番良いですが。

逆にexportの仕方が間違ってるだとかモジュールの構造がおかしいとかになると捨てたほうが得策になることが多いです。

捨てた場合

noImplicitAnyが有効な場合、TSでは型定義ファイルがないとimport自体できないので何かしら用意する必要があります。最小のモジュール定義は

// hoge.d.ts
declare module 'hoge' {}
// anotherFile.ts
import * as h from "hoge";

のような感じになります。

noImplicitAnyが無効の場合、node_modules内にモジュールがインストールされていれば型定義ファイルなしでもimportできます。ちゃんとした型定義ファイルがあるのであれば使ったほうが良いですが、型定義ファイルというTSの独自概念が必要なくなるので、よりJSっぽく使えるという意味でnoImplicitAnyを無効にするかの1つの判断ポイントになります。

将来的にはチームの型定義ファイルへの理解が進んでいくと思うので型定義ファイルを自力でも用意する方法に切り替えていくのをおすすめします。

型定義ファイルを捨てるというとネガティブな印象を受けますが、結局型定義ファイルを捨ててもJSになるだけです。モジュールまるごとanyになるのは辛いですが、自分で定義を書くことは可能なので、型定義ファイルの理解に応じて少しずつ育てていけばいいと思います。

node_modules内に含まれる型定義ファイルが間違っていた場合

最近のTSの型定義ファイルの管理方法の標準はnpm @types/hogeという感じでnpmで管理する方法ですが、他にもモジュール本体と一緒に型定義ファイルが提供されていることがあります。最近だとElectronの場合が有名です。Electronレベルのプロダクトになれば間違ってて困ることはないですが、本体に同梱されていてそれの定義ファイルがおかしいという場合があります。@typesで別のモジュールとしてインストールする場合捨てるという選択肢がありますが、同梱されている場合その選択が取れずどうにかして外部から修正するしかありません。

どうすればいいかというと頑張って外部から修正するかPRを送るしかないです。使わなくても良いモジュールであるのならばモジュールを使うこと自体諦めたほうがいいと思いますが、TSの事情に引きづられるということなので気に入らないという人もいるかと思います。

このシチュエーションはエッジケース(手書きの型定義ファイルを同梱してくれるプロジェクトが少ない)なのでそんなに考慮する必要はないですが、ハマる上に解決も難しいポイントなので触れておきます。

TSへの移行を"がんばらない"話

がんばらなくても良くなった話です。

一般的には既存のJSプロジェクトの場合Flowがいいよと言われる事が多いと思いますが、最近は別にそうでもないのでその紹介です。

移行のための武器

  • allowJsオプション
    • .jsファイルを読み込めるように
    • .jsから.jsへのコンパイルが可能なので簡易Babelのようなことが可能
  • checkJsオプション
    • .jsファイルへの型チェック
    • 型推論の範囲+JSDoc形式のコメントを解釈してくれる
    • 参考: TypeScript 2.3 · TypeScript

allowJsによって

[src .js(ES2015)] =Babel=> [dis .js(ES5)] =Broawrifyなど=> ...

のようなビルドパイプラインのBabel部分をTypeScriptに入れ替えればそのまま動くようになります。理論上。

[src .js(ES2015)] =TypeScript=> [dis .js(ES5)] =Broawrifyなど=> ...

あとは

  1. checkJSをファイルごとに有効にする
  2. エラーを潰す
  3. .tsに拡張子を変える

を繰り返せば移行完了となります。

数年前はい移行作業の1ステップが大きかったのでそんなに難しい作業ではないとは言え思い切りが必要でしたが、最近はかなり順次導入できる環境になりました。

移行のコツ

いちばん重要なのは「"今動いている.js"を信用し、.tsにすることを目指す」ことだと思います。型に関するエラーはコンパイル結果には影響しないので無視し、後ほど必要になったら適切に直すことをおすすめします。jsへの影響のない範囲で書き換えていくとレビューもスムーズだと思われます。

移行は中途半端になっている状態が一番つらいので早めにゴール(.ts化)を目指しましょう。

CoffeeScriptからの移行

事前にお伝えした発表タイトルだとCoffeeScript移行が全面に押し出されていましたが実は大して苦労しなかった(ので主題を変えた)というのが本音です。

方法としては.coffeeはESとして解釈できないので

  1. ES2015に変換する
  2. 諦めてコンパイル結果をsrcとして使う

のどちらかの方法を取る必要があります。今回はそんなに規模が大きくなくclass構文なども使われていなかったので2.の手法を選びました。

CoffeeScript特有の事情としては暗黙のreturnがあって関数の返り値がなんかおかしいというものがありましたが、"今動いている.js"を信用するの方針に従い一旦無視して進めました。その後完全に.ts化した後returnする必要のない部分は削っていく作業をちまちましました。

まとめ

がんばらないTypeScriptの始め方と称してかなり"意識の低い"TSの導入の仕方を紹介しました。TS自体もともとJSが存在しているということを強く意識して作られている言語です。いきなり高度なことをやるのではなく、最初は簡易Babelくらいに思って漸次的にやっていくという考え方も理にかなったものだと思います。その際落とし穴になる型定義ファイルとの付き合い方も示しました。

一点注意してほしいのはTSのキャッチアップしなくていいという話はしていません。導入の敷居は下げても良いと思いますが、キャッチアップし続ける覚悟は必要かと思います。

もともとTSをゆるく導入しても良いという考え方は、社内のバラバラのTSスキルという問題に対して回答です。とりあえずTSを導入してしまってチームとしてのスキル向上はおいおいやれば十分やっていけるというTSの漸次導入の容易性に根ざした経験則です。ですからもちろんチームのキャッチアップはサポートすべきですし、チームの理解度が高い状態であるならば"がんばるTS"をすべきです。