「がんばらないTypeScript」のための現実的な設定を考える ─ 4レベルの厳しさを使い分けてTypeScript疲れを克服しよう!

エンジニアHubでは2019年に「がんばらないTypeScript」を紹介しました。JavaScriptに静的型付けなどを提供するTypeScriptは、今では実プロジェクトに採用されるプログラミング言語になっています。そこで現実的なTypeScriptの設定を、藤吾郎(gfx)さんに解説してもらいました。

「がんばらないTypeScript」のための現実的な設定を考える ─ 4レベルの厳しさを使い分けてTypeScript疲れを克服しよう!

2021年の現在、TypeScriptの価値はますます広く認められるところとなり、多くのJavaScriptプロジェクトがTypeScriptで開発されるようになってきました。またTypeScriptコンパイラは日進月歩で機能が追加され、静的解析や補完などの開発補助機能もますます拡充しており、より便利になっています。

一方で「TypeScript疲れ」あるいは「TypeScript忌避」とでもいえる意見も増えてきたように思います。これにはいくつか理由があるでしょうが、TypeScript自体に起因する多様性と複雑さ、そしてTypeScriptのベースとなっているJavaScriptに起因する複雑さが入り乱れているからだと筆者は分析しています。

そこで本記事では、前半で現状のTypeScriptの多様性や複雑さを「TypeScript忌避」の観点から分析し、そしてTypeScriptの設定のあり方について段階を示します。後半では、NodeJSとWebpackを採用するプロジェクトにTypeScriptを導入するやり方を紹介します。

なぜTypeScriptへの評価が分かれるのか

TypeScriptへの評価が分かれるのは、1つにはTypeScriptの設定の自由度が非常に高いからだと考えられます。ほかのプログラミング言語と比較すると、設定の自由度が高過ぎると言ってもいいくらいです。

まず、TypeScriptではコンパイラ組み込みの型チェックや整合性のチェックなど、静的解析の厳しさをかなり自由に設定できます。最もゆるい設定と、最も厳しい設定では、静的解析の内容がかなり異なります。このため、その設定がプロジェクトメンバーの習熟度や好みに合っていなければ、TypeScriptの負の側面を強く感じてしまうかもしれません。

さらに、TypeScriptの設定はアップデートごとに次々と追加されており、全てを把握するのが困難なことも難しさの1つかもしれません。

TypeScript言語のみならず、そのエコシステムも複雑です。例えば、lintにはtypescript-eslintが最も広く使われていますが、そのルールセットにデファクトスタンダードといえるものはありません。さらにJavaScriptの言語とエコシステムが持つ複雑さが、そのまま上積みされてきます。

次節からは、上記それぞれの多様性や複雑さを概説し、その後いくつかのステップについて適切な設定を紹介します。

TypeScriptコンパイラの設定の多様性 ─ noImplicitAnyの例

TypeScriptコンパイラの設定の多様性については、2019年4月に掲載した「TypeScript再入門 ― 「がんばらないTypeScript」で、JavaScriptを“柔らかい”静的型付き言語に」でも解説したので多くは語りませんが、その中でも特に議論になるのはnoImplicitAnyです。

デフォルトはnoImplicitAny: falseで、ある型を推論できなかったときは暗黙的にanyにフォールバックします。また、JavaScriptのESモジュールをimportするときに、対応する型宣言ファイル(.d.ts)がないとanyになります。

これがnoImplicitAny: trueになると、型推論できないケースと、型宣言ファイル不在のimportの双方でエラーになります。型推論できないケースでは型注釈を付け、型宣言ファイルが不在の場合は、マジックコメント// @ts-expect-errorなどで対応することになります。

TypeScript 4.4時点では、tsc --initで生成されるデフォルトのtsconfig.jsonではstrict: trueになっています。noImplicitAnystrictに含まれるため、デフォルトのtsconfigではnoImplicitAny: trueも有効になります。しかし、これはどんなTypeScriptプロジェクトnoImplicitAny: trueでなければならないという意味ではありません。

TypeScript設定ファイルのドキュメントは「TSConfig Reference」にあります。ただし2021年7月現在では、型チェック系のオプションがstrictに含まれるかどうかの記載がありません。コンパイラオプションのドキュメント「tsc CLI Options」はその点がカバーされているので、この2つのページを併用して確認するとよいでしょう。

lintの設定の多様性

lint設定の多様性も非常に大きなものです。TypeScriptで利用できるlintは、typescript-eslintがデファクトスタンダードです。これはeslintをTypeScriptに対応させるためのプラグインで、TypeScriptに対応するための実装とlintルール集からなります。

この設定が多様かつ複雑なのはある程度やむを得ません。そもそもがeslintという基盤の上にtypescript-eslintを構築しているため、ルールセットが二重構造になっています。eslint自体のルールの多くはTypeScriptでも利用可能ながら、一部はTypeScriptだと使えません。そしてあるルールがTypeScriptで有効かどうかは使ってみなければ分かりません。

ルールの数も非常に多く、全てを把握するのは困難です。そしてeslintもtypescript-eslintも日々アップデートされて、ルールが次々と追加されています。こうなると「typescript-eslintの設定はプロジェクトに応じて育てるもの」と認識して取り組むほかありません。

なお、typescript-eslintにはrecommendedという奨励ルールセットが同梱されています。これはベースのルールセットとしては便利ですが、これだけでは不足だったり過剰だったりします。特に、recommendedで有効になる多くのルールが、エディタ上ではエラーとして報告されるerrorであることが個人的には不満です。lintルールは、よほどのことでもないかぎり、エディタ上で警告として扱われるwarn相当でよいはずです。そこで「recommendedは設定するが、特別扱いせずにルールは気軽に上書きする」という軽い気持ちで接するくらいでよいと思います。

ところでプロジェクトに取り入れるtypescript-eslintのルールセットですが、サードパーティ製のルールセットライブラリを、パッケージマネージャでインストールして使うのは避けるべきだと考えています。パッケージマネージャで管理すると、サードパーティのルールセットは余計なレイヤーになり、運用がより複雑になります。例えば、本来はルールセットに正解はないのに、サードパーティ製のルールセットが「正解」のように感じられ、なんとなく不満を持ちつつそのルールセットのためにコードを修正する、といったことが起きてしまいます。

サードパーティ製のルールセットは先人の知見の集積ですから、積極的に使ってよいとは思います。しかし、サードパーティ製ルールセットを利用するときは、そのルールセットをプロジェクトにコピーしましょう。そして、必要に応じて気軽に改変し、もとのルールセットも余裕があれば定期的に参考にするくらいの使い方がよいと思います。

参考までにmsgpack-javascript/.eslintrc.jsは、筆者がメンテナンスしているライブラリのためのtypescript-eslintのルールセットです。後述する厳しさのレベルでいえば、最も厳しい「レベル4」に相当します。これが全てのプロジェクトにとってベストだとはまったく思いませんが、参考にはなるでしょう。

TypeScriptの設定と恩恵の多様性

TypeScriptおよびlintの設定に多様性があるのはそういうものとして、実際どうやってプロジェクトにとって最適な設定を決めるべきでしょうか。

まず「厳しい」設定、つまり型チェックや整合性のチェックを可能な限り行うような設定は、コンパイル時、あるいはlint実行時にバグを発見できる可能性が高まります。また、厳しい設定のもとで型付けをしたコードは、エディタの補完などの恩恵を受けやすいため、生産性が高まります。

しかし、型付けの保守にはある程度コストがかかりますし、厳しく型付けられたコードで型エラーが起きると読解困難なエラーが出ることも少なくありません。結果、「厳しい」設定のもとでは、プロジェクトに関わるメンバー全員がTypeScriptに熟達する必要があります。

一方で「ゆるい」設定の場合は、コンパイル時に検出できるバグは「厳しい」設定と比較して少なくなり、エディタのサポートの恩恵も減ります。とはいえ、品質に関してはテストを厚めに書くことでフォローできますし、まさにそれはJavaScriptプロジェクトでやっていることです。

そしてプロジェクトに関わるメンバーのTypeScriptへの熟練度も、「厳しい」設定ほどには不要です。それでも、JavaScriptよりはコンパイル時チェックにせよエディタのサポートにせよ強力です。

プロジェクトメンバーの納得感は「TypeScriptの恩恵」より重要

TypeScriptの設定の厳しさと恩恵の強力さは、トレードオフの関係にあります。一方で、「厳しい」設定でストレスなく開発するためには、TypeScriptとJavaScript双方の習熟が必要です。

筆者は、TypeScriptで得られる恩恵よりも、プロジェクトメンバーがストレスを感じずに楽しく開発できることを重視して、設定の厳しさを調整するべきだと考えています。プロジェクトメンバーの習熟度を超える「厳しさ」にするとTypeScriptのコストに苦しみます。一方でプロジェクトメンバーの習熟度よりはるかに「ゆるい」設定では、得られる恩恵が少なく不満に思うかもしれません。

TypeScriptの設定の厳しさには正解はなく、プロジェクトメンバー間で合意を取りつつちょうどよい設定を探るべきだと思っています。また設定は常に柔軟であるべきです。厳し過ぎる制約は一時的にせよ恒久的にせよ外してみて、ゆる過ぎるようであればより強い恩恵を求めて厳しめに変えてみながら、最適な設定を探っていきましょう。

JavaScriptでも得られるTypeScript処理系の恩恵

ところで本記事はTypeScriptに焦点を絞っていますが、TypeScript処理系はJavaScriptを処理する機能も提供しています。TypeScriptはJavaScriptを拡張した言語であり、主に型注釈の構文を付け加えただけで、JavaScriptの構文の意味づけ(セマンティクス)自体は変えなかったからこそ、TypeScriptの型推論や静的解析ロジックの成果をJavaScriptにそのまま適用できたのでしょう。

これにより、素のJavaScriptプロジェクトでもTypeScript処理系の恩恵をある程度受けることができます。

TypeScriptの言語サービスはJavaScriptも対象にする

TypeScriptの言語サービスがJavaScriptも対象にすることを確認してみます。まず、VS Codeで下記のようなJavaScriptファイルをそのまま開いてみます。

// purejs-add.js

function add(x, y) {
    return x + y;
}

console.log(add(1, 2));

ここでadd(1, 2)にカーソルを合わせると、function add(x: any, y: any): anyというTypeScriptで表現された関数宣言がツールチップとして表示されるはずです。これは、TypeScriptコンパイラがJavaScriptのコードを読み込みながら、TypeScriptであるかのように型推論をしているからです。

この機能は無設定でJavaScriptファイルを開くことでも自動的に提供されますが、TypeScript設定ファイルによって明示的に、そしてよりTypeScriptに近い形で解釈させることができます。

"allowJs"と"checkJs"で「ほぼTypeScript」に

TypeScriptの設定allowJs: truecheckJs: trueは、JavaScriptのコードをTypeScriptコンパイラに処理させるためのオプションです。

allowJsを設定すると、TypeScriptコンパイラがプロジェクト中のJavaScriptファイルをビルドするようになります。実用的には、TypeScriptからもJavaScriptファイルを参照できるようになるほか、JavaScriptファイルでもTypeScriptコンパイラによるダウンレベルが行われるようになります。

checkJsは、allowJsによってビルド対象になったJavaScriptファイルも静的解析の対象にします。これにより、TypeScript同様の型推論やフロー解析によるエラーが報告されるようになります。さらに、次のようにJSDoc形式のコメントで型注釈を書いて型推論を補助することさえできます。

// purejs-with-annotations.js
/**
 * @param {number} x
 * @param {number} y
 */
function add(x, y) {
    return x + y;
}

console.log(add(1, "foo"));
//                 ^^^^^
// > Argument of type 'string' is not assignable to parameter of type 'number'.
// 型ミスマッチでエディタ上はエラーに見える

このJavaScriptファイルを編集中は"foo"に赤線が引かれてエラーに見えますし、tscコマンドでコンパイルしようとすると実際にエラーになります。まさに、JavaScriptでありながら同時に「ほぼTypeScript」同様に開発ができるというわけです。ただし、それでもTypeScriptと比べて一部の型チェックは意図的に省略されます。詳しくは「Type Checking JavaScript Files」に解説があります。

ところで、allowJsのもとでTypeScriptファイルがJavaScriptモジュールをimportするとき、JavaScriptの静的解析の結果が考慮され、ある程度「JavaScriptの型が見える」状態になります。例えば、次に示すようなTypeScriptプロジェクト内のJavaScriptコードは、function add(x: any, y: any): anyexportしているように見えます。

// add.js
// tsconfig.json では `allowJs: true` を設定
// なお `checkJs: true` + `noImplicitAny: true` の場合はJSDocでの型注釈が必要
export function add(x, y) {
  return x + y;
}
// import-add.ts
import { add } from "./add.js"; // ".js" は省略可能
console.log({ z: add(1, 2) }); // => { z: 3 }
//               ^^^
// "add" にカーソルを合わせると "add(x: any, y: any): any" というツールチップが表示される

ただし、このallowJsによるJavaScriptモジュールの推論は、プロジェクト内のJavaScriptファイルにしか効果がありません。つまり、残念ながら、プロジェクト外のNPMモジュールにはこの型推論が効きません。もし将来的に、NPMモジュールでも型定義ファイルのないJavaScriptモジュールをimportできるようになれば、noImplicitAny: trueのもとでも型定義ファイルなしのJavaScriptを、ある程度型のついた状態でimportできるようになるかもしれません。

それでもTypeScriptを採用すべきである理由

さて、JavaScriptはTypeScriptコンパイラに処理させることで、TypeScriptに近い開発が可能であることを示しました。それでは、あえてコストを払ってTypeScriptを使う必要はないのでしょうか?

確かに、TypeScriptを使わない方がいいようなプロジェクトもあるかもしれません。例えば、JavaScriptが主要言語ではないプロジェクトで、JavaScript製のソフトウェアを使いたいときです。ブラウザを使ったE2EテストのためにPuppeteerを使いたい場合など、JavaScriptそれ自体への関心がないケースでは、プロジェクトメンバーにTypeScriptの習得を強制する必要はないかもしれません。

しかし、JavaScriptを主要言語とするプロジェクトであれば、JavaScriptをTypeScriptのように使うのではなく、TypeScriptそのものを使う方がいいでしょう。JavaScriptにJSDocで型注釈を付けることはできますが、前述の通りTypeScriptより冗長で機能も貧弱なので、TypeScriptの方が書きやすく、しかも堅牢です。

TypeScriptの設定の落とし所を探る

さて、それでは「ゆるい」設定から「厳しい」設定に程よくステップアップするためのガイドラインを紹介します。繰り返しになりますが、これのいずれかが正解というわけではありません。あるプロジェクトでどのレベルを目指すかは、プロジェクトの性質とメンバーの習熟度に依存します。本記事はあくまでもおおまかなガイドラインとして参考にすることを想定しています。

レベル1: better JavaScript

まずレベル1です。このレベルでは、TypeScriptの任意の型チェックや整合性のチェックを全て無効にします。このレベルは、既存のJavaScriptプロジェクトをTypeScriptプロジェクトに変換するときに有用です。始めからTypeScriptで開発するプロジェクトでは、次に説明するレベル2にするのがよいでしょう。

既存のJavaScriptプロジェクトを「TypeScript レベル1」にするには、まず.js.tsにリネームします。そしてtsconfig.jsonstrict: truefalseにします。

- "strict": true,
+ "strict": false,

strict: falseは、厳しいチェックを抑制します。TypeScript 4.4時点ではほかに初期状態で有効になっているチェック系オプションはないので、これが最も「ゆるい」状態です。

JavaScriptからTypeScriptに移行するケースで、もともと純粋なJavaScriptしか使ってないならば移行は簡単かと思います。一方でBabelを使っている場合、特に非標準の機能や、標準JavaScriptのプロポーザル機能を提供するプラグインを使っているときには、移行でトラブルが起きがちです。TypeScriptは、ある程度標準化の見込みが立った新機能しか取り入れないからです。

とにかくTypeScriptへの移行を素早く終わらせるためチェック系オプションを使わない、というのがレベル1の意義です。無事に移行できたら、なるべく早くレベル2を目指しましょう。つまり、少しずつ型チェックや整合性のチェックを行うようにします。設定に正解なしとはいうものの、レベル1にとどまるのは奨励できません。

レベル2: がんばらないTypeScript

レベル2の設計思想は、まさに前回の記事で解説した通りです。レベル2は確固たる設定というよりは、先ほど説明したnoImplicitAny以外のチェック系オプションを、時間をかけて1つひとつ有効にしていくプロセスといえます。

チェック系オプションには、大きく分けてstrict: trueに含まれる「strict系オプション」と、strictに含まれない「その他のチェック系オプション」があります。

レベル2におけるstrict系オプション

strict系オプションは、TypeScript 4.4現在、次のようなものがあります。noImplicitAnyをfalseのままにする以外は、全てtrueになるように開発を進めてください。

オプション 説明
alwaysStrict 常にJavaScriptにおけるstrict modeで解釈し、出力されるJavaScriptにも"use strict"を追加する
noImplicitAny: false trueでは型が推論不能のときにコンパイルエラーにする。falseでは型が推論不能なときにany型にする
noImplicitThis thisの型が推論不能のときコンパイルエラーにする
strictBindCallApply 歴史的経緯により正しく型付けされていなかったFunctionのbind() call() apply()メソッドを厳格に型付けする
strictFunctionTypes 歴史的経緯により存在したFunction型の誤った型変換が許されなくなる
strictNullChecks anyunknown以外の任意の型Tについてnullundefinedが暗黙のうちに含まれなくなる。nullを代入したければT | nullなど明示が必要
strictPropertyInitialization コンストラクタ内でundefinedが許されないプロパティが全て代入されているかどうかチェックされる
useUnknownInCatchVariables catchの例外変数のデフォルトの型がunknownになる

これらのオプションはstrict: trueに含まれるので、strictを有効にするのであれば個別に設定する必要があるのはnoImplicitAny: falseだけになります。

この中でuseUnknownInCatchVariablesはstrict系であるものの、型チェックや整合性チェックの強化ではなく、catch(err)の例外変数errのデフォルトの型をanyではなくunknownにするオプションです。TypeScriptでは、コンパイル時にどの型の例外が起きるか決定できないため、例外変数はanyunknownしかあり得ません。このオプションで、例外変数はinstanceofなどで型ガードを半強制されるunknownになります。

なお、strict系オプションはTypeScriptコンパイラのアップデートで増えることもあり、すると「TypeScriptコンパイラをアップデートしたらコンパイルエラーが出るようになり修正を迫られる」という状況になります。しかし、それは基本的にコードの品質を上げるために必要な修正です。

レベル2でおすすめのその他のチェック系オプション

次のオプションはstrictには含まれませんが、有用なので設定を奨励します。

オプション 説明
noImplicitReturns 関数の戻り値がvoid以外のときにreturnを必須にする
noFallthroughCasesInSwitch switch文で、caseをbreakやreturnで終えていることを必須にする
noUncheckedIndexedAccess indexシグネチャ([key: Key]: Val)を使うとき、値の型にundefinedを加える
noPropertyAccessFromIndexSignature indexシグネチャをプロパティアクセスのとき使わなくする
noImplicitOverride スーパークラスのメソッドをオーバーライドするとき、overrideを必須にする
importsNotUsedAsValues: "error" 型コンテキストでしか使わないシンボルをimportするとコンパイルエラーにする。import typeを使うこと

ほかにnoUnusedLocalsおよびnoUnusedParametersもその他のチェック系で、未使用のローカル変数や引数があるとコンパイルエラーにします。しかし、開発中は未使用変数があるだけでエラーになると煩わしいので、設定しなくてもよいでしょう。

その代わり、typescript-eslintのルール@typescript-eslint/no-unused-vars: "warn"で警告扱いにすると、未使用変数のコンパイルエラーに都度対応することなく開発しつつ、かつ最終的にも未使用変数を残さずに済むのではないかと思います。

レベル3: 依存関係の下流の設定

レベル3はレベル2とほぼ同じですが、noImplicitAny: trueを設定することを目指します。目指しますが、必ずしも有効にしなければならないわけではありません。そして並行して、補完的にtypescript-eslintでより厳しくしていきます。

レベル3と次の4は、設定の違いというより思想の違いです。レベル3の「依存関係の下流」とは、アプリケーションプロジェクト内でほかのコンポーネントから依存されないコンポーネントです。このような依存関係の下流にあるコンポーネントは、ほかのコンポーネントに再利用されないため、そこまで厳しく型付けする必要はありません。

これは「厳しくすべきではない」という意味ではありませんが、依存関係の上流で再利用されるコンポーネントをきちんと型付けすること(レベル4)に労力を注ぐべきだと思っています。

レベル4: 依存関係の上流用の設定

レベル4の「依存関係の上流」とは、ライブラリプロジェクトやアプリケーションプロジェクトの中で再利用されるコンポーネントです。まさに労力を注いできちんと型付けすべき場所であり、最高レベルに厳格なオプションを設定します。そしてコンポーネントが繰り返し利用されることで、厳格な型付けが報われます。

設定上は、レベル4ではnoImplicitAny: trueを設定すべきというくらいで、ほとんどレベル3と違いはありません。しかし、意識の上ではまったく別です。ほかのメトリクス、例えばテストカバレッジは、レベル4ではより高い水準を目指すべきです。

また、再利用されるコンポーネントには、より厳しいlintルールを適用するのもよいでしょう。eslintはディレクトリごとにeslintrcで追加ルールセットを設定できます。追加ルールセットはプロジェクトルートのルールセットにマージされます。

Configuration Files - Cascading and Hierarchy

つまり、依存関係の上流・下流でコンポーネントのディレクトリを分けることで、異なる水準のtypescript-eslintルールセットを設定できます。

レベル4で設定したいtypescript-eslintルール

レベル4でのみ奨励するtypescript-eslintのルールを1つ紹介します。

次のドキュメントにある@typescript-eslint/explicit-module-boundary-typesは、exportする関数やクラスについて明示的な型注釈を要求します。ルールの強さはwarnでよいでしょう。

Require explicit return and argument types on exported functions' and classes' public class methods (explicit-module-boundary-types) - typescript-eslint/typescript-eslint

このルールを特定のディレクトリ以下のTypeScriptファイルにのみ効かせたいときは、そのディレクトリに次のような.eslintrc.jsファイルを置きます。

module.exports = {
  "rules": {
    "@typescript-eslint/explicit-module-boundary-types": "warn",
  },
};

新規プロジェクトにTypeScriptを導入する場合の設定

ここからは、実際に新規のプロジェクトにTypeScriptを導入する際に適した設定を紹介します。チェック系オプションの厳しさは、アプリケーションを想定して「レベル2: がんばらないTypeScript」とします。

NodeJS用の新規プロジェクトを作る場合

NodeJSのプロジェクトには、次のような特徴があります。

  • 新しいNodeJSは、新しいJavaScriptの機能の多くをサポートしている。このためTypeScriptでダウンレベルする必要がない
  • 同様にポリフィルも必要ない
  • NodeJS自体はES modulesをサポートしているが、TypeScriptのES modules対応は不完全

これを踏まえてプロジェクトを作成してみます。

$ mkdir hello-nodejs
$ cd hello-nodejs
$ npm init -y
$ npm install -D typescript ts-node @types/node
$ npx tsc --init
$ echo build/ >> .gitignore
$ echo node_modules/ >> .gitignore
$ git init && git add . && git commit -m 'initial commit'

続いてtsconfig.jsonを編集します。チェック系オプションは、前述したようにレベル2相当です。加えて、nodejsの最新版を使うという想定でtargetlibes2021を指定するほか、よく使うオプションも設定します。

diff --git a/tsconfig.json b/tsconfig.json
index 194d0d1..5d21dee 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -4,9 +4,9 @@

     /* Basic Options */
     // "incremental": true,                         /* Enable incremental compilation */
-    "target": "es5",                                /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */
+    "target": "es2021",                                /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */
     "module": "commonjs",                           /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
-    // "lib": [],                                   /* Specify library files to be included in the compilation. */
+    "lib": ["es2021"],                                   /* Specify library files to be included in the compilation. */
     // "allowJs": true,                             /* Allow javascript files to be compiled. */
     // "checkJs": true,                             /* Report errors in .js files. */
     // "jsx": "preserve",                           /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */
@@ -14,7 +14,7 @@
     // "declarationMap": true,                      /* Generates a sourcemap for each corresponding '.d.ts' file. */
     // "sourceMap": true,                           /* Generates corresponding '.map' file. */
     // "outFile": "./",                             /* Concatenate and emit output to single file. */
-    // "outDir": "./",                              /* Redirect output structure to the directory. */
+    "outDir": "./build",                              /* Redirect output structure to the directory. */
     // "rootDir": "./",                             /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
     // "composite": true,                           /* Enable project compilation */
     // "tsBuildInfoFile": "./",                     /* Specify file to store incremental compilation information */
@@ -26,7 +26,7 @@

     /* Strict Type-Checking Options */
     "strict": true,                                 /* Enable all strict type-checking options. */
-    // "noImplicitAny": true,                       /* Raise error on expressions and declarations with an implied 'any' type. */
+    "noImplicitAny": false,                       /* Raise error on expressions and declarations with an implied 'any' type. */
     // "strictNullChecks": true,                    /* Enable strict null checks. */
     // "strictFunctionTypes": true,                 /* Enable strict checking of function types. */
     // "strictBindCallApply": true,                 /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
@@ -37,14 +37,14 @@
     /* Additional Checks */
     // "noUnusedLocals": true,                      /* Report errors on unused locals. */
     // "noUnusedParameters": true,                  /* Report errors on unused parameters. */
-    // "noImplicitReturns": true,                   /* Report error when not all code paths in function return a value. */
-    // "noFallthroughCasesInSwitch": true,          /* Report errors for fallthrough cases in switch statement. */
-    // "noUncheckedIndexedAccess": true,            /* Include 'undefined' in index signature results */
-    // "noImplicitOverride": true,                  /* Ensure overriding members in derived classes are marked with an 'override' modifier. */
-    // "noPropertyAccessFromIndexSignature": true,  /* Require undeclared properties from index signatures to use element accesses. */
+    "noImplicitReturns": true,                   /* Report error when not all code paths in function return a value. */
+    "noFallthroughCasesInSwitch": true,          /* Report errors for fallthrough cases in switch statement. */
+    "noUncheckedIndexedAccess": true,            /* Include 'undefined' in index signature results */
+    "noImplicitOverride": true,                  /* Ensure overriding members in derived classes are marked with an 'override' modifier. */
+    "noPropertyAccessFromIndexSignature": true,  /* Require undeclared properties from index signatures to use element accesses. */

     /* Module Resolution Options */
-    // "moduleResolution": "node",                  /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
+    "moduleResolution": "node",                  /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
     // "baseUrl": "./",                             /* Base directory to resolve non-absolute module names. */
     // "paths": {},                                 /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
     // "rootDirs": [],                              /* List of root folders whose combined content represents the structure of the project at runtime. */
@@ -60,12 +60,14 @@
     // "mapRoot": "",                               /* Specify the location where debugger should locate map files instead of generated locations. */
     // "inlineSourceMap": true,                     /* Emit a single file with source maps instead of having a separate file. */
     // "inlineSources": true,                       /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
+    "sourceMap": true,

     /* Experimental Options */
     // "experimentalDecorators": true,              /* Enables experimental support for ES7 decorators. */
     // "emitDecoratorMetadata": true,               /* Enables experimental support for emitting type metadata for decorators. */

     /* Advanced Options */
+    "resolveJsonModule": true,
     "skipLibCheck": true,                           /* Skip type checking of declaration files. */
     "forceConsistentCasingInFileNames": true        /* Disallow inconsistently-cased references to the same file. */
   }

最後に、次の内容でsrc/index.tsを作ります。

function hello(s: string): string {
    return `Hello, ${s}!`;
}

function main() {
    console.log(hello("world"));
}

main();

ts-nodeで起動して、うまく動作すれば成功です。

$ npx ts-node src/index.ts
Hello, world!

なお、package.jsonmainフィールドをsrc/index.tsにしておくとnpx ts-node .で起動できるようになります。本番用のビルドはnpx tscbuild/ディレクトリにJavaScriptファイルの成果物が生成されます。

最低限の設定はこれだけです。非常にシンプルな構成ですね。

NodeJSプロジェクトのCIをGitHub Actionsで行う

CIはどんなプロジェクトにも必要です。特にTypeScriptプロジェクトでは、コンパイルが通るかどうかを確認するだけのCIでも価値があります。テストはおいおい追加するとして、素早くCIを設定しましょう。GitHubの場合、次のようなYAMLファイルを用意すればすぐにGitHub ActionsでCIを始められます。

# .github/workflow/ci.yml
name: CI

on:
  push:
    branches: [ main ]
  pull_request:
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
        with:
          node-version: '16'
      - name: Install dependencies
        run: npm ci
      - name: Build
         run: |
          npx tsc

チームで合意した「厳しさ(あるいはゆるさ)のレベル」を維持するためにも、CIは必ず設定してください。

typescript-eslintをCIで実行する

typescript-eslintを導入するのであれば、必ずCIでも実行するようにしましょう。まず最小限の設定のために、eslintとtypescript-eslintをインストールします。

# 最小限のモジュール
npm install --save-dev eslint typescript @typescript-eslint/parser @typescript-eslint/eslint-plugin

本当に最小限の設定は、typescript-eslintのREADMEにある次のようなものです。

// .eslintrc.js
module.exports = {
  root: true,
  parser: '@typescript-eslint/parser',
  plugins: [
    '@typescript-eslint',
  ],
  extends: [
    'eslint:recommended',
    'plugin:@typescript-eslint/recommended',
  ],
};

また、.eslintrc.js自体はlintの対象にはしたくないため、.eslintignoreというファイルを次のような内容で作ります。

.eslintrc.js
# tsconfigで指定したoutDir: "./build" もignoreしておく
build/

とはいえ、これだけではさすがに過不足がありすぎるので、+αとして少しだけ追加のプラグインとルールセットを紹介します。まず、プラグインは次の通りです。

# +αのモジュール
npm install --save-dev eslint-plugin-import eslint-config-prettier eslint-plugin-prettier

設定はこのようにします。これは設定の厳しさでいうとレベル2~3くらいを想定し、かつシンプルさを重視したルールセットです。もちろんこれでも過不足はありますが、いずれにせよ最適なルールセットはプロジェクトとメンバー次第で異なるので、気軽に消したり足したりしてください。

// .eslintrc.js
module.exports = {
  root: true,
  extends: [
    // https://eslint.org/docs/rules/
    "eslint:recommended",

    // https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/src/configs/recommended.ts
    "plugin:@typescript-eslint/recommended",

    // eslint-plugin-import
    // https://github.com/benmosher/eslint-plugin-import
    "plugin:import/recommended",
    "plugin:import/typescript",

    // eslint-config-prettier & eslint-plugin-prettier
    // https://prettier.io/docs/en/integrating-with-linters.html
    // > Make sure to put it last, so it gets the chance to override other configs.
    "prettier",
  ],
  plugins: [
    "@typescript-eslint",
  ],
  parser: "@typescript-eslint/parser",
  parserOptions: {
    project: "./tsconfig.json",
  },
  settings: {},
  rules: {
    "import/no-unresolved": "off",
    "import/no-cycle": "error",
    "import/no-default-export": "warn",

    "@typescript-eslint/array-type": ["warn", { default: "generic" }],
    "@typescript-eslint/naming-convention": [
      "warn",
      { "selector": "default", "format": ["camelCase", "UPPER_CASE", "PascalCase"], "leadingUnderscore": "allow" },
      { "selector": "typeLike", "format": ["PascalCase"], "leadingUnderscore": "allow" },
    ],
    // 以下はrecommendedに入っているものの、多くの場合厳しすぎるためoff
    // ただし前述のようにレベル4では"warn"にすべき
    "@typescript-eslint/explicit-module-boundary-types": "off",

    // 以下はrecommendedに入っているものの、実用的には厳しすぎるためoff
    "@typescript-eslint/no-explicit-any": "off",
    "@typescript-eslint/no-empty-interface": "off",
    "@typescript-eslint/no-empty-function": "off",
    "@typescript-eslint/no-var-requires": "off",
    "@typescript-eslint/no-non-null-assertion": "off",
    "@typescript-eslint/ban-ts-comment": "off",
  },
};

ところで、このルールセットでimport/no-cycleだけがerrorなのは、意図的なものです。eslintのルールで、コーディングスタイルに関わるものはwarn(警告)にし、設計ミスやコーディングミスなどロジックの品質に関わるものは、十分吟味した上でerror(エラー)にするとよいでしょう。

import/no-cycleは、ESモジュールのimportで循環参照するのを禁止するルールです。コンポーネントの依存関係が循環するのは明らかに設計ミスと思われます。

さて、この設定をCIで実行するには、前節の.github/workflow/ci.ymlにeslintを実行するステップを加えます。

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bf1abf0..1912170 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -20,3 +20,6 @@ jobs:
       - name: Build
         run: |
           npx tsc
+      - name: Lint
+        run: |
+          npx eslint .

これで、eslintのerrorの違反があるとCIが失敗します。つまり、errorを指定したルールは必達です。これに対して、warnの違反があってもCIはパスするので、こちらは努力目標といえます。

Webpackで新規プロジェクトを作る場合の設定

最後に、Webpackの設定を作成してみます。まず必要なモジュールをインストールします。ついでにポリフィル用にcore-jsもインストールしておきます。

$ npm install -D webpack webpack-cli ts-loader core-js

そして、次のようなwebpack.config.jsを作ります。TypeScriptに必須なのはts-loaderの設定です。

"use strict";

const isProduction = process.env.NODE_ENV === "production";
const assetDir = "assets";

const config = {
  mode: isProduction ? "production" : "development",

  entry: {
    application: [
      "core-js",
      "./src/index.ts",
    ],
  },
  target: ["web", "es2021"],
  output: {
    publicPath: `/${assetDir}/`,
    path: `${__dirname}/public/${assetDir}`,

    filename: isProduction ? "[name]-[chunkhash].js" : "[name].js",
  },

  resolve: {
    modules: ["src", "node_modules"],
    extensions: [".js", ".jsx", ".ts", ".tsx"],
    alias: {},
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        loader: "ts-loader",
        options: {
          configFile: "tsconfig.json",
          transpileOnly: !isProduction,
        },
      },
    ],
  },

  optimization: {
    minimize: isProduction,
  },
  devtool: "source-map",
};

module.exports = config;

オプションのtranspileOnlyは、trueのときに静的解析を一切行わず、JavaScriptへのコンパイルのみを行います。これを有効にすると、開発モードのときにTypeScriptプロジェクト全体で型の整合性が取れていなくても、ビルドできるようになります。

開発中やリファクタリング中には、しばしば一部のコンポーネントをある程度作った段階で、動作確認したくなります。そのようなときにtranspileOnlyで全体の整合性を無視できるため、言い換えればJavaScriptと同程度の構文チェックだけで「とりあえず動かす」ことができます。プロダクションビルド時に指定すべきではありませんが、開発モードでは便利な設定です。

NodeJS用のTypeScriptプロジェクトにおいては、tsconfig.jsonにおけるnoEmitOnError: falseが、transpileOnly: trueに似ています。tsconfigはほかのファイルを継承できるので、noEmitOnError: falseのみを設定したtsconfigを開発用に用意する手もあります。

このようにtsconfigファイルを、用途に合わせていくつか用意することはよくあります。筆者がメンテナンスしているmsgpack-javascriptには、7個ものtsconfigがありました。これはさすがに多過ぎる事例ですが、複数用意することでビルドプロセスをシンプルにできるなら複数用意するとよいでしょう。

最後に

ここまで説明したように、NodeJSからWebpackまでを実行可能な状態にしたリポジトリを下記に用意しました。ご参照ください。

gfx / hello-typescript-2021

藤 吾郎(ふじ・ごろう) 2@__gfx__ / 3gfx

4
株式会社ディー・エヌ・エー、クックパッド株式会社、株式会社ビットジャーニーでのソフトウェアエンジニア経験を経て、2021年現在ファストリー株式会社に勤務。インターネットとプログラミングが好きで、ツールやライブラリをOSSとして多数公開している。
ブログ:Islands in the byte stream

編集:はてな編集部

【修正履歴】ご指摘により一部誤記を修正しました。(2021年8月30日16時)