自分のペースに合わせ、JavaScriptプロジェクトをTypeScriptに移行する方法

January 06, 2020
執筆者

how to move your project to ts - JP

この記事はTwilio Developer AdvocateのDominik Kundelこちらで執筆した記事を日本語化したものです。

 

誰もが経験したことがあるでしょう。JavaScriptの機能やバグに取り組み、ようやく完了したと思いコードを実行したところ、無常にも「undefined is not a function(undefinedは関数ではありません)」というエラーが表示された、のような事態を。私はJavaScriptが大好きですが、プロジェクトのコードベースが大きくなるにつれ、面倒な状況に陥ることが何度もあります。コードベースの大部分を簡単にリファクタリングできることをはじめ、前述の「undefined is not a function」や類似のバグを回避することなど、JavaScriptの型に対するアプローチには誰もが悩まされてきました。このような問題の解決を支援するツールの1つとして、最近人気を集めているのがTypeScriptです。

TypeScriptは、JavaScriptに型システムを導入します。コードに対する理解が深まり、開発フローを支援してくれます。しかし、誰もが認識しているものの口にしたがらない、重要な問題についても早めに説明しておきます。多くの開発者は、静的型付け言語のように、すべてに型を追加する必要がない方が嬉しいものです。そもそも静的型付け言語を選択する理由もそこにあるかもしれません。

TypeScriptに興味がなくとも、この記事を読んでみてください。TypeScriptに移行する予定がなくても、メリットがあるかもしれません。例えば、Webpackのようなプロジェクトは、JavaScriptのままでTypeScriptを使用することに成功しています。このことについては記事の後半で説明します。

この記事は、自分のペースに合わせ必要な分だけプロジェクトにTypeScriptを取り入れることを前提にしています。どの章の後でも、一度立ち止まり、実際に動くコードベースを入手し、開発者のエクスペリエンスを少し向上させることができるでしょう。

この記事は、言語としてのTypeScriptを紹介するものではありません。代わりに、TypeScriptをツールとして活用し、自分のニーズに合わせて、自分のペースで移行する方法を紹介します。

すでにTypeScriptを使用しているかもしれない

これまでにTypeScriptを1行も書いたことがないと考えている人でも、実際はTypeScriptを書いたことがあり、TypeScriptのツールも使用したことがある可能性は高いです。

まず覚えていただきたいのは、TypeScriptがJavaScriptのスーパーセットであるということです。つまり、有効なJavaScriptはすべて有効なTypeScriptであるということです。正常に動作するJavaScriptを書いた場合、有効なTypeScriptを書いたことになります。しかも、明示的に型を宣言する必要はありません。TypeScriptは常に可能な限り型を推測しようとします。

すべての有効なJavaScriptが有効なTypeScriptであることを踏まえると、TypeScriptを導入したツールを使用したことがある可能性も高いでしょう。Monacoエディタは、Visual Studio CodeCodeSandboxStackblitzなどのさまざまなツールを支えているエディタです。TypeScriptを採用し、優れたオートコンプリートコードリファクタリングなどの機能を提供しています。つまり、このようなエディタのオートコンプリートやリファクタリング機能を使用したことがある人は、TypeScriptを使用していたということです。

VS CodeによるJavaScriptのペアプログラミング

実は、私はほとんどTypeScriptコンパイラやTypeScriptファイルを使用していません。代わりに、通常のJavaScriptパイプラインツールを使用しています。しかし、私とペアプログラミングをしてくれるエディタにおいて、今もあらゆる段階でTypeScriptの力を借りています。

VS Codeには、エディタ全体または特定のワークスペースやプロジェクトについてのみ有効にできる設定があり、JavaScriptのTypeScriptチェックを有効にすることができます。

プロジェクトに対してこの機能を有効にするには、プロジェクトのルートディレクトリに.vscode/settings.jsonファイルを作成し、その中に次の内容を記述します。

{ 
    "javascript.implicitProjectConfig.checkJs": true 
}

または、同じルールをグローバルエディタの設定に配置するか、設定UIを使用してワークスペースの設定を編集することもできます。

これで、完全にタイプセーフではない行を書いた場合、TypeScriptがエディタ内でそのコードをマークしてくれます。例えば、このコードでは問題が発生します。

let PORT = process.env.PORT || 3000; PORT += 1;

これは、process.env.PORTstring型であり、また、3000number型であるためです。これは、PORT += 1が2つの異なる型の結果において等しくなることを意味します。最初のケースでは、文字列に「1」が追加され、2番目のケースでは、数字の3001という結果になります。

vs code - ts error

注意する必要があるのは、これらのエラーメッセージはエディタにしか表示されないため、コードに影響を与えないということです。このようなエラーが発生しても、コードは実行されるかもしれません。しかし同時に、テスト中に熟慮してこなかったシナリオについては警告を受けたことになります。

この設定は、意図的にある型のルールに違反している場合には、厄介に感じられるかもしれません。その場合、この設定が気に入らなければ、この設定を無効にするか、行の上に次のコメントを追加することにより、行ごとの型チェックを無効にすることができます。

// @ts-ignore

全体の型チェックを無効にするには、次を使用できます。

// @ts-nocheck

個人的にはこの設定が好きで常にオンにしています。自分が書くJavaScriptのすべての行について、考えたこともないようなエッジケースがあるかもしれないと、いつも考えさせられるからです。常に見張り、助けてくれるペアプログラミングの相棒のようなものです。

JavaScript用TypeScriptコンパイラの使用

VS Codeにおいて有効にした設定は、実際にはcheckJsというTypeScriptコンパイラの設定をセットするものです。この設定は、TypeScriptパーサーを通じてJavaScriptファイルを実行するallowJsという別の設定に関連しており、これらを組み合わせれば、既存のJavaScriptプロジェクトを段階的にTypeScriptに移行するスタートとしては完璧と言えます。

その仕組みを説明するため、次のプロジェクトを例として説明します。github.com/dkundel/ts-move-demo

プロジェクトのクローンを作成したり、自分のプロジェクトに手順を適用したりして、自由に活用してください。

TypeScriptコンパイラのインストール

まず、TypeScriptコンパイラをインストールする必要があります。コンパイラはnpmにtypescriptとして公開されており、tscというコマンドラインユーティリティを提供しています。コンパイラはグローバルにインストールすることもできますが、プロジェクトごとにdevの依存関係としてインストールすることにより、プロジェクトごとに異なるバージョンのコンパイラを使用できます。

プロジェクトのTypeScriptコンパイラをローカルにインストールするには、以下を実行します。

npm install -D typescript

また、package.jsonscriptsセクションに新しいbuildスクリプトをセットアップすることもできます。

"scripts": { 
    "start": "node .", 
    "build": "tsc", 
    "test": "echo \"Error: no test specified\" && exit 1" 
},

これでビルドしたいときはいつでもnpm run buildを実行できます。また、ウォッチモードでTypeScriptを実行したい場合、npm run build -- --watchを実行するか、tsc --watchを呼び出す別のスクリプトを定義できます。

TypeScript構成の作成

たくさんの構成オプションをコマンドライン引数を介してTypeScriptコンパイラに渡したくない場合は、プロジェクトにtsconfig.jsonファイルを作成します。これには2つの方法があります。手動でファイルを作成する方法と、tsc --initを実行し、デフォルトの設定を作成する方法です。このケースでは、tscを呼び出すbuildスクリプトが設定されているため、次を実行することができます。

npm run build -- --init

その後、プロジェクト内にtsconfig.jsonが作成されます。これには利用可能なすべての設定と、それらの役割を説明するコメントが含まれています。コメントアウトされているものもあれば、TypeScriptがベストプラクティスとみなすものに設定されているものもあります。

これらの設定は新しいプロジェクトには適していますが、今回のユースケースには最適ではありません。

JavaScript用TypeScriptコンパイラの構成

最初のステップとして、TypeScriptコンパイラがJavaScriptファイルを読み込むようにします。

既存のtsconfig.jsonを次のように置き換えます。

{
 "compilerOptions": {
   "target": "esnext" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */,
   "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
   "allowJs": true /* Allow javascript files to be compiled. */,
   "checkJs": false /* Report errors in .js files. */,
   "outDir": "./dist" /* Redirect output structure to the directory. */,
   "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */,
   "strict": false /* Enable all strict type-checking options. */,
   "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
   "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
 }
}

この構成では、デフォルトの設定から5項目を変更しました。

  1. "target": "esnext": esnextに変更しました。これは、TypeScriptがモダンJavaScript機能の下位トランスパイルを実行しないことを意味します。これにより、Babelのようなツールを使用したトランスパイルを続けることができます。値をes5などに変更し、コードがどのように変化するか自由にテストしてみてください
  2. "allowJs": true: TypeScriptコンパイラが検出したあらゆるJavaScriptファイルを読み込み、TypeScriptコンパイラを通じて実行することを意味します
  3. "checkJs": false: ここではJavaScriptファイルの型チェックを無効にし、コンパイラに正しく渡されているかどうかだけを確認します
  4. "outDir": "./dist": コンパイラが読み込んだすべてのファイルが処理された後、rootDirの位置に基づいて新しいdist/ディレクトリに配置されます。
  5. "rootDir": "./src": 一部のユースケースでは一般的にこの設定を省略できますが、特にJSファイルとTSファイルの両方を含む段階的な移行の場合、すべてのコードをsrclibなどコードのルートとしたい任意のディレクトリに配置するようにします。これにより、コンパイラはすべてのコードを簡単に見つけることができ、distフォルダ内で構造を再現する方法を認識します。
  6. "strict": false: TypeScriptに拒否されることなく、プロジェクトをTypeScriptに首尾よく移行させるために、ここではstrictモードを無効にします。

今回のプロジェクトでは、すべてのファイルがすでにsrc/フォルダに移動されていますが、別のプロジェクトで試す場合、または別のフォルダを使用する場合は、ファイルを移動するか、rootDirの値を調整する必要があります。

また、ソースファイルを参照するパスを、新しいコンパイル済みファイルに対応させる必要もあります。この例では、package.jsonファイルのmainエントリを更新します。

- "main": "src/index.js",
+ "main": "dist/index.js",
 "scripts": {

設定がすべて完了したら、変更内容をテストしましょう。次のコマンドを実行します。

npm run build

すべてのJavaScriptファイルを含む新しいdistフォルダが作成されます。target値を変更した場合はトランスパイルされる可能性があります。

その後コードを実行し、これまと同じく動作しているかどうかを確認できます。この例では、次のコマンド実行します。

npm start

そして、http://localhost:3000を開き、サーバーが稼働しているかどうか確認します。

TypeScriptを使用したJavaScriptのチェック

すべてのJavaScriptがTypeScriptコンパイラを通過するようになりました。次にJavaScriptの型チェックを有効にします。そのためには、tsconfig.jsonのフラグを更新します。

{
 "compilerOptions": {
   "target": "esnext" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */,
   "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
   "allowJs": true /* Allow javascript files to be compiled. */,
   "checkJs": true /* Report errors in .js files. */,
   "outDir": "./dist" /* Redirect output structure to the directory. */,
   "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */,
   "strict": false, /* Enable all strict type-checking options. */
   "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,

   "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
 }
}

Node.jsやElectronなど、DOMに属さないグローバルオブジェクトを使用している場合は、このステップで必要なtypesをインストールする必要があります。Node.jsの場合、次を実行します。

npm install -D @types/node

typesをインストールし、続いてコードをコンパイルします。今回はいくつかの型エラーが発生する可能性があるため、TypeScriptコンパイラをwatchモードで実行し、ファイルを修正している間継続的に再実行されるようにします。

npm run build -- --watch

TypeScriptは、JavaScriptのコードに対する型を可能な限り推測しますが、JSDocコメントも考慮します。

VS Codeを使用し、checkJsフラグを有効にしてプロジェクトを記述した場合、ほとんど修正はないでしょう。このステップで発生するほとんどのエラーは、アクセスする前にdocument.getElementById()の結果がnullでないかの確認など、チェック漏れに関連するものでしょう。または、推測された型に基づいて実際に発生するエラーかもしれません。

いずれの場合も、これまでに考えもしなかったようなものが見つかるかもしれません。また、意図的なコードについては、コードの行の上に// @ts-ignoreコメントをつけると、TypeScriptはそのコードを無視します。

隠れたバグの発見

この設定を行い、コンパイラが検出したエラーを修正したら、この時点で停止することもできます。これはWebpackが実際に使用しているアプローチです。WebpackはJavaScriptを使用して書かれていますが、エラーのチェックにはTypeScriptを使用します。2018年4月にWebpackがTypeScriptを導入したとき、コンパイラによりさまざまなバグを発見できました。コードベースがすべて徹底的にテストされていたにもかかわらずです。

webpack - typescript added

Webpackのように、コードをトランスパイルすることなくTypeScriptを使用したい場合は、次のように構成を更新することができます。

{
 "compilerOptions": {
   "target": "esnext" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */,
   "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
   "allowJs": true /* Allow javascript files to be compiled. */,
   "checkJs": false /* Report errors in .js files. */,
-   "outDir": "./dist" /* Redirect output structure to the directory. */,
   "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */,
+   "noEmit": true /* Do not emit outputs. */,
   "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
   "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
 }
}

この場合、コンパイラが追加のリンターのように動作します。

JavaScriptファイルをTypeScriptファイルに変換

ファイルがJavaScriptにおいて可能な限りタイプセーフであることを確認したら、次のステップでは、それらのファイルをゆっくりと1つずつTypeScriptファイルに変換していきます。すでにallowJsを有効にしているため、一度にすべてのファイルを移動する必要はなく、徐々に移行していくことができます。

JavaScriptコードはすでに型チェックを実行したため、更新が必要な部分はあまりありません。

最初のステップは、ファイル拡張子を.jsから.tsに変更することです。これにより、TypeScriptコンパイラにファイルがJavaScriptではなくTypeScriptであることを知らせ、型の定義など追加の構文機能が有効になります。

最も更新が必要になりそうなのは、値のimportexportの方法です。すでにES Modules構文を使用している場合、ここでの作業は必要ありません。CommonJS構文を使用(基本的にrequire()module.exportsを使用)している場合、TypeScriptはES Modules構文を使用するため、更新が必要となります。

それぞれのrequire()呼び出しを同等のimport構文に置き換え、すべてのexports.*exportに変更し、module.exportsを排除します。

src/routes/data.jsファイルがsrc/routes/data.tsに変換されると、次のようになります。

- const { Router } = require('express');
+ import { Router } from 'express';
 
- const routes = Router();
+ export const routes = Router();
 
routes.get('/', (req, res) => {
 res.send({
   hello: 'world',
 });
});
 
- module.exports = routes;

重要な注意点はmodule.exportsはES Modulesのexport defaultとは異なるということです。同等のものはありません。export default xは、実際にはexports.default = xとより同等ですが、esModuleInteropオプションをtsconfig.jsonに設定しているため、module.exportsを使用してJavaScriptファイルをimportしようとすると、TypeScriptが処理します。

import express from 'express'

しかし、TypeScriptを使用して書いたファイルをJavaScriptから要求する場合、これでは動作しません。つまり、このケースでは、インポートファイルを次のように更新する必要があります。

- const dataRoutes = require('./routes/data');
+ const { routes: dataRoutes } = require('./routes/data');
// or in ES Modules / TypeScript
+ import { routes as dataRoutes } from './routes/data';

一般的な経験則として、module.exports = xを使用してデフォルトのエクスポートのように動作させることは回避すべきです。代わりに、各プロパティを個別にエクスポートするか、module.exports = { default: x }と設定し、ES Moduleとの互換性を保ちます。

すべてのインポートを修正した後、TypeScriptコンパイラを再実行し、正常にパスするかどうかを確認します。その後にアプリを再実行し、すべてが適切に動作するかを確認します。確認できたら、すべてのファイルに対して同じプロセスを徐々に繰り返し、プロジェクトをTypeScriptに移行していきます。

厳密化: 型の追加

ファイルは、TypeScriptに変わりました。次はTypeScriptに情報を提供し、TypeScriptの力を発揮させましょう。これまでのところ、JSDocコメントを使用していない限り、何をしようとしているかについて、実際にはTypeScriptにあまり伝えていません。

TypeScriptには、型チェックをより厳密にするためのオプションがいくつかあります。それらは次のとおりです。

  // "noImplicitAny": true,                 /* 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. */
   // "strictPropertyInitialization": true,  /* Enable strict checking of property initialization in classes. */
   // "noImplicitThis": true,                /* Raise error on 'this' expressions with an implied 'any' type. */
   // "alwaysStrict": true,                  /* Parse in strict mode and emit "use strict" for each source file. */

TypeScriptに徐々に取り組みたい場合、1つずつ有効にしていきます。すべての問題を一度に解決したい場合は、代わりにtsconfig.jsonで次のオプションを設定することができます。

"strict": true,

これで設定をすべて一度に有効にできます。

エラーの中には一部のライブラリに型が不足しているというものがあります。Node.js用の型をインストールする必要があったのと同じように、これらライブラリ用の型をインストールする必要があります。多くのライブラリに型が付属するようになったため、すべてのライブラリに対してこの作業を行う必要はありません。しかし、JavaScriptを使用して書かれたライブラリの中には、自分で型を維持していないものもあります。代わりに、DefinitelyTypedプロジェクト下でコミュニティにより維持されている型に頼る必要があります。例えばExpressJSの場合、次を実行してコミュニティの型をインストールする必要があります。

npm install -D @types/express

その型を自分のプロジェクト内でのみ使用し、独自の型を入れたライブラリのオーサリングをしてはいない場合、すべての型をdev dependenciesとしてインストールする必要があります。もう1つのケースについては後で説明します。

さまざまなstrictフラグを有効にした場合、表示されるエラーを必ず修正します。

その後、オプションでコードに型を追加し、さらに強力な型付けをすることができます。この例では、データルートがペイロードデータとして返せるものをより明確にしたいと思います。そのために、Payloadという新しいtypeを定義し、追加のヘルパー型をインポートして、src/routes/data.tsファイルの型を更新します。

import { Router, Request } from 'express';
import { Response } from 'express-serve-static-core';

type Payload = {
 hello: 'world'
}

export const routes = Router();

routes.get('/', (req: Request, res: Response<Payload>) => {
 res.send({
   hello: 'world',
 });
});

この変更で{ "hello": "world" }を持つオブジェクトのみを送り返すことができるようになります。他の文字列に変更したり、他のプロパティを追加したりしようとすると、拒否されます。その後、type Payloadの定義を変更し、ペイロードを調整することができますが、ここでは他の人が参照できる、チェックが容易なコントラクトを持つことになります。

コードベースがTypeScriptになるにつれ、より多くの型を追加することになるでしょう。型の定義に役立つさまざまなユーティリティ機能について、TypeScriptハンドブックtype-festなどのライブラリで学習することができます。

TypeScriptによるライブラリの維持

社内外を問わず、他者と共有するライブラリを維持している場合、ビルドチェーンにTypeScriptを含めることを検討してもよい理由がもう1つあります。その理由は、型宣言ファイル(別名: TypeScript定義ファイル)です。これは、他者がそのライブラリをTypeScriptにより使用する際に使用されるファイルです。Visual Studio Codeなどのエディタは宣言ファイルを使用し、ライブラリのオートコンプリート情報を取得します。

これらの宣言ファイルは、2つの方法で配布することができます。1つは、ライブラリと一緒に直接配布する方法です。つまり、公開されたプロジェクトのどこかに(理想的にはトランスパイルされたJavaScriptと並べて)配置し、package.jsontypesプロパティにより、mainのJavaScriptファイルに対応する正しいファイルを指定します。これについては、TypeScriptドキュメントで詳しく説明されています。

これらの宣言ファイルは、tsconfig.jsondeclarationsプロパティをtrueに設定することにより作成できます。

{
 "compilerOptions": {
   "target": "esnext" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */,
   "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
   "allowJs": true /* Allow javascript files to be compiled. */,
   "checkJs": true /* Report errors in .js files. */,
   "declaration": true /* Generates corresponding '.d.ts' file. */,
   "outDir": "./dist" /* Redirect output structure to the directory. */,
   "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */,
   "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,

   "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
 }
}

上記を設定すると宣言ファイルがJavaScriptと一緒に直接生成されます。TypeScriptバージョン3.7からは、allowJsdeclarationを組み合わせることができるようになりました。つまり、プロジェクトがJavaScriptだけで書かれていても、手作業で維持する必要なく、基本的なTypeScript宣言ファイルを生成することができるようになりました。

他者がライブラリの型を使用する別の方法は、コミュニティプロジェクトであるDefinitelyTypedを利用する方法です。誰でもプルリクエストを作成し、宣言ファイルを追加・更新し、宣言を自己公開しないライブラリとすることができます。

I am here to help

大切なことは、TypeScriptというツールを活用できるかは、あなた次第ということです。使うのも使わないのも自由です。しかし、良い友人関係と同じように、ただそれを利用するのではなく、自分から何かを投入すると、より良いものになります。TypeScriptは、コードの型を可能な限り推測してくれます(そして常に改善されていきます)。しかし、TypeScriptにさらなる力を発揮してもらいたいと思うなら、時には自分からアシストする必要があります。

これらのステップがTypeScript使いこなすための助けとなり、TypeScriptがプロジェクトの成長に役立つことを願います。私は個人的に、これらのステップを使用して、twilio-runなどのプロジェクトをJavaScriptからTypeScriptに移行できました。デモプロジェクトに適用されたステップを確認したい場合、関連するプルリクエストをチェックしてください

ご質問があれば、お気軽にお問い合わせください。喜んでお手伝いします。