Babelを使ってJavaScriptで書いていたライブラリをTypeScriptへマイグレーションする方法についてのメモ書きです。 数十回はライブラリやアプリケーションのコードベースをJavaScriptからTypeScriptへ変換しているので、 ある程度やり方がパターン化されています。

この記事では、自分がよく利用している次の構成のライブラリを元にしています

この構成を、次のようなTypeScriptを使った構成へと変換していきます。

Babelで書かれたライブラリをTypeScriptへ変換

今回はtextlint-rule-helperというライブラリを例にしていきます。 このライブラリは次の記事で、Babelを使ってライブラリを書く構成として紹介しています。

このBabelを使って書いたライブラリのソースコードをTypeScriptへ変換し、配布するところまでを見ていきます。

実際に変換したPull Requestは次のURLで見れます。

Babelでの構成

ざっくりとBabel版でのtextlint-rule-helperは次のような構成になっています。 (v2.0.1がBabelでのソースコードになっています。)

File Tree in Babel

  • src/
    • ES2015で書いたソースコード
  • lib/
    • Babelで変換した結果(ES5)のソースコード
    • 実際のライブラリとして配布するのはこちら
    • 変換結果(自動生成)なのでGitの管理下には置かないように.gitignoreで無視する
    • 一方で配布はするのでpackage.jsonfilesフィールドでnpmにはpublishする
  • test/
    • ES2015で書かれたテストコード
    • @babel/registerを利用して、実行時に変換しながらテストする

詳しい構成については次の記事で解説しているので合わせてみてください。

TypeScriptでの構成(予想図)

この記事の最終的な構造の予想図は次のようになっています。

File tree in TypeScript

  • src/
    • TypeScriptで書いたソースコード
  • lib/
    • TypeScriptで変換した結果(ES5)のソースコード
    • 実際のライブラリとして配布するのはこちら
    • 変換結果(自動生成)なのでGitの管理下には置かないように.gitignoreで無視する
    • 一方で配布はするのでpackage.jsonfilesフィールドでnpmにはpublishする
  • test/
    • TypeScriptで書かれたテストコード
    • ts-nodets-node-test-registerを利用して、実行時に変換しながらテストする

変換するのがTypeScriptに変わった以外は、Babelの構造と何も変わっていないことがわかります。

Babel to TypeScript

実際にBabel to TypeScirptへの変換をしていきます。

次の記事でもJavaScriptからTypeScriptへの移行方法について書いています。

textlint-rule-helperはソースコードとテストコードどちらもTypeScriptにへ変換する予定です。 大まかなやり方は上記の記事と同じで、次のような流れでTypeScriptへ変換していきます。

  1. TypeScriptをインストールする
  2. TypeScript(tsc)でJavaScriptをビルドできるようにする
  3. TypeScript(tsc)でJavaScriptのテストを通るようにする
  4. ソースコード(src/)をTypeScriptへ変換する
  5. テストコードを(test/)をTypeScriptへ変換する

1. TypeScriptをインストールする

まずは、TypeScriptなどの必要な依存をまとめてインストールします。 ここでは、ts-nodeなどあとで必要になるものをまとめています。

npm install --save-dev \
typescript \
ts-node \
ts-node-test-register \
mocha \
@types/node \
@types/mocha

2. TypeScript(tsc)でJavaScriptをビルドできるようにする

ここでは、ソースコードをJavaScriptのままTypeScript(tscコマンド)でビルドできるようにします。 TypeScriptはallowJsというオプションによって、JavaScript(ES2015+)をJavaScript(ES5)へと変換するTranspilerとして利用できます。いままで、BabelでやっていたのはES2015 -> ES5の処理だったので、これをTypeScriptでやるように移行していきます

JavaScriptをビルドするtsconfig.jsonを作成

まずは、tsc --initコマンドで、tsconfig.jsonファイルを作成します。

既にtsc(TypeScriptコンパイラ)がnode_modules以下にインストールされていると思うので、tsc --initコマンドを叩くことでデフォルト設定のtsconfig.jsonを作成します。

./node_modules/.bin/tsc --init
# or
npx tsc --init
# or
yarn tsc --init

デフォルトのtsconfig.json.tsファイルをビルドする設定になっているので、 JavaScriptファイルもビルドできるようにallowJsオプションを有効化します。

{
  "compilerOptions": {
    /* Basic Options */
    "module": "commonjs",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "newLine": "LF",
    "outDir": "./lib/",
    "target": "es5",
    "sourceMap": true,
    // == 変更点 == 
    "declaration": false,
    "allowJs": true,
    // == 変更点 == 
    "jsx": "preserve",
    "lib": [
      "es2018",
      "dom"
    ],
    /* Strict Type-Checking Options */
    "strict": true,
    /* Additional Checks */
    /* Report errors on unused locals. */
    "noUnusedLocals": true,
    /* Report errors on unused parameters. */
    "noUnusedParameters": true,
    /* Report error when not all code paths in function return a value. */
    "noImplicitReturns": true,
    /* Report errors for fallthrough cases in switch statement. */
    "noFallthroughCasesInSwitch": true
  },
  "include": [
    "src/**/*"
  ],
  "exclude": [
    ".git",
    "node_modules"
  ]
}

Tips: 最近の--initではesModuleInteroptrueになっています。 このオプションは有効化した方がBabelのimportと挙動が近くなるのでtrueにしておいたほうがマイグレーションは簡単です。

tscでJavaScriptをビルド

次にnpm run buildでビルドできるように、package.jsonscriptsを次のように変更します。

npm run buildでビルド、npm run watchでファイル監視とビルド、npm testでテストという感じです。

  "files": [
    "lib"
  ],
  "main": "lib/index.js",
  "types": "lib/index.d.ts",
  "scripts": {
    "build": "tsc -p .",
    "prepublish": "npm run --if-present build",
    "test": "mocha \"test/**/*.{js,ts}\"",
    "watch": "tsc -p . --watch"
  },

これで、npm run buildコマンドでsrc/以下にあるJavaScriptファイルをビルドしてlib/へ出力できるようになります。

Note: npm run buildが通らない場合

Babelで特殊な処理をしている場合はtscではビルドできない場合もあります。 (ソースコードにECMAScript外の記法が利用されていたり、import a from "~/hoge"のような拡張をしている場合) textlint-rule-helperでは@babel/preset-envのみの利用となっています。

Tips: 手作業でscriptsを書き換えるのが面倒な場合はnpeコマンドを使うと、CLIで書き換えができるので簡単です。

npx npe scripts.build "tsc -p ."
npx npe scripts.watch "tsc -p . --watch"
npx npe scripts.prepublish "npm run --if-present build"
npx npe scripts.test "mocha \"test/**/*.{js,ts}\""
# types
declare currentDir=$(pwd)
declare currentDirName=$(basename "${currentDir}")
npx npe types "lib/${currentDirName}.d.ts"

これでソースコードをとりあえずtscでビルドできるようになりました。 ただし、型チェックなどもなにもしてないので、ビルドしたものが実行できるかはわかりません。

次は、既存のテストを同じようにallowJsで実行してテストを通るようにしていきます。

3. TypeScript(tsc)でJavaScriptのテストを通るようにする

元々をMocha + @babel/registerを利用していたのを、ts-nodets-node-test-registerへ移行します。

まずは、テストコード向けのtsconfig.jsonを作成します。 次のようにtest/tsconfig.jsonという場所に作成します。 わざわざ、テスト用のtsconfig.jsonを作成しているのは、設定を分けたほうが少しづつ移行しやすいのとテスト用に設定を分けたほうが柔軟性が高いためです。

test/
├── IgnoreNodeManager-test.js
├── mocha.opts
├── tsconfig.json
└── textlint-rule-helper-test.js

test/tsconfig.jsonには、先ほど作成したルートのtsconfig.jsonを継承して、次のような設定で上書きします。

  • declarationallowJs はJavaScriptファイルを扱うため
    • extendsしているので特に書く必要はないが、あとでルートのtsconfig.jsonからは削除するため重複して書いている
  • noEmittrueにしてビルドしてもファイルを出力しないようにする
  • includeに、テストファイル自身(./**/*)とソースコードのディレクトリを指定する
{
    "extends": "../tsconfig.json",
    "compilerOptions": {
        "declaration": false,
        "allowJs": true,
        "noEmit": true
    },
    "include": [
        "../src/**/*",
        "./**/*"
    ]
}

そして、mocha.optsで読み込むregsiterを@babel/registerからts-node-test-registerへ変更します。

- --require @babel/register
+ --require ts-node-test-register

ts-nodeは実行時にTypeScriptを変換してくれるライブラリで、@babel/register相当の処理を行います。 ts-node-test-registerts-nodeのラッパーで、test/tsconfig.jsonを自動的に読み込むようにしたテスト用のregisterです。(ts-nodeはパスを設定しないとルートのtsconfig.jsonを読み込みます)

そして、JavaScriptで書かれたテストをnpm testコマンドで実行します。

npm test

これでテストが通ってるならOKです。 この時点でプロジェクトからBabelの依存はなくなりました。

Notes: テストが通らない

ここでテストが通らないのは、テストコードにBabelに依存した処理があるのかもしれません。 Fixturesを動的に読み込んでいる場合などの挙動で違いが出るケースもあります。

また、JSDocのようなエラーメッセージがでているなら、checkJs: trueとなってるのかもしれません。 (JSDocの型チェックが行われていて、型が間違っていると通りません)

このサンプルであとでTypeScriptに変換するので、checkJs: falseでもいいかもしれません。

4. ソースコード(src/)をTypeScriptへ変換する

次に、やっとコードをTypeScript(.ts)へと変換していきます。

ここでやることはsrc/*.jsを一個ずつ.tsへと変換していくだけです。

基本的な考え方:

  • .tsへ拡張子を変更
  • npm testが通るまで型をつけていく
  • .js.tsは一応混在してても動くので、1つずつ.tsにしていく
    • 1ファイル変換できたらコミットする(巻き戻せるように)

変換していく順番の考え方:

  • 依存の末端から.tsにしていく
    • そのファイルが何もimportしていないファイルが一番最初
    • 依存がないファイルは変換が簡単
  • ライブラリは@types/*があるかを確認する
  • JSDocが書かれているとWebStormやVScodeのQuickFixで推測してTypeScriptの型をつけてくれる
    • ついてなくてもInfer typeから結構推測してつけたりできる
    • とりあえずテストコードの変更は最小で型をつけていけばいいので、anyとかでもいいはず
    • 実際にちゃんとした型をつけるには、使う側(テストコードなど)をTypeScriptにしないと難しいので
  • テストはできるだけ変更しない
    • テストが既に動いてるはずなので、できるだけテストコード自体は変更しないようにしてテストを通す
    • テストのロジックをうっかり壊さないようにする

infer type on VSCode

これでsrc/*.jssrc/*.tsに変換されました。

ルートのtsconfig.jsonからallowJsを外す

ソースコードは.tsになったため、ルートのtsconfig.jsonからallowJsを外してビルドできるかを確認します。

  • allowJsを消す
  • declarationtrue
{
  "compilerOptions": {
    /* Basic Options */
    "module": "commonjs",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "newLine": "LF",
    "outDir": "./lib/",
    "target": "es5",
    "sourceMap": true,
    // == 変更点 == 
-    "declaration": false,
+    "declaration": true,
-    "allowJs": true,
    // == 変更点 == 
    "jsx": "preserve",
    "lib": [
      "es2018",
      "dom"
    ],
    /* Strict Type-Checking Options */
    "strict": true,
    /* Additional Checks */
    /* Report errors on unused locals. */
    "noUnusedLocals": true,
    /* Report errors on unused parameters. */
    "noUnusedParameters": true,
    /* Report error when not all code paths in function return a value. */
    "noImplicitReturns": true,
    /* Report errors for fallthrough cases in switch statement. */
    "noFallthroughCasesInSwitch": true
  },
  "include": [
    "src/**/*"
  ],
  "exclude": [
    ".git",
    "node_modules"
  ]
}

この状態でnpm run buildしてlib/以下へ.d.ts.jsが生成されていれば成功です。

5. テストコードを(test/)をTypeScriptへ変換する

最後にテストコードもTypeScriptへ変換していきます。

これもソースコードと同じように.js.tsにしていくだけです。 基本的な考え方はソースコードの場合と同じです。

次の記事でも書いていましたが、テストコードは普通はファイルごとに独立しているので、必要になったら変換していく形でも問題ありません。

ソースコードの型定義をちゃんとしたい場合は、テストコードもTypeScriptで書いておくと自然とまともな型になっていきます。

テストコードも.tsに変更できたらtest/tsconfig.jsonからもallowJsを外して完成です。

{
    "extends": "../tsconfig.json",
    "compilerOptions": {
        "declaration": false,
-        "allowJs": true,
        "noEmit": true
    },
    "include": [
        "../src/**/*",
        "./**/*"
    ]
}

Notes: .tsにするとテストコードが通らなくなる

忘れがちですが、.jsという拡張子に依存している処理が存在しているケースもあります。 特に動的に.jsfsで読み込んでいる場合などは、ファイルが存在しなくなるため失敗することもあります。

npm へ publishする

基本的にはBabelのときと同じようにlibをnpmへpublishします。 型定義ファイルも生成されるようになったので、typesフィールドを追加して型定義ファイルも配布するのを忘れないでください。

{
    // typesにd.tsのパスを追加する
    "main": "./lib/main.js",
    "types": "./lib/main.d.ts"
}

これで使う側も、TypeScriptで型付きのライブラリとして利用できます。

Notes: Babelの不要なファイルを削除する。

TypeScriptへ移行できたら、Babelの依存は不要なので削除しても大丈夫です。

  • .babelrc
  • devDependenciesのbabel

また、lib/には古い変換結果が残っている場合があるので、一度lib/を消してしまうのが良いでしょう。

おわりに

この記事では、Babelで書いていたライブラリをTypeScriptへ変換する方法を紹介しました。

textlint-rule-helperライブラリをES2015(ES6)で書いて公開する所から始めよう | Web ScratchでBabelでライブラリを書く例として紹介しています。 この記事では、そのライブラリをTypeScriptへ変換しました。

今のTypeScriptは、Babelからの変換はかなりスムーズに行えます。

実際にこの記事で書いた変換は、30分程度でできています。

記事では省略せずに書いていますが、次のスクリプトでステップ1-3まではほぼ自動化できます。 (スクリプトにはコピー元のファイルが入ってなかったり、そのままではビルドは通らないですが)

  1. [自動] TypeScriptをインストールする
  2. [自動] TypeScript(tsc)でJavaScriptをビルドできるようにする
  3. [自動] TypeScript(tsc)でJavaScriptのテストを通るようにする
  4. ソースコード(src/)をTypeScriptへ変換する
  5. テストコードを(test/)をTypeScriptへ変換する
#!/bin/bash
# variable
declare scriptDir=$(cd $(dirname ${BASH_SOURCE:-$0}); pwd)
declare currentDir=$(pwd)
declare currentDirName=$(basename "${currentDir}")

# dependecy script
npm install npe sort-package-json --global

function echo_message(){
  echo "\033[31m=>\033[0m \033[036m$1\033[0m"
}
# Install
echo_message "npm install"
yarn add --dev --pure-lockfile \
typescript \
ts-node \
ts-node-test-register \
mocha \
@types/node \
@types/mocha \
cross-env
# Copy config
echo_message "Copy .tsconfig mocha.opts"
mkdir -p test
## !!!!ここはコピー元のファイルが必要なのでこのままでは動かない
cp ${scriptDir}/resources/tsconfig.json ./
cp ${scriptDir}/resources/test.tsconfig.json ./test/tsconfig.json
cp ${scriptDir}/resources/typescript.mocha.opts ./test/mocha.opts
# Edit package.json
## Add script
echo_message "Add npm run-script"
npe scripts.build "cross-env NODE_ENV=production tsc -p ."
npe scripts.watch "tsc -p . --watch"
npe scripts.prepublish "npm run --if-present build"
npe scripts.test "mocha \"test/**/*.{js,ts}\""
npe types "lib/${currentDirName}.d.ts"
sort-package-json
# git
git add .

BabelからTypeScriptへの移行は典型処理も多いので、慣れるとそこまで難しくはありません。 TypeScriptに変換することで型定義ファイルを配布できたり、型チェックが利用できるというメリットもあります。 また、外部に型定義ファイルを作成するよりも、ソースコード自体をTypeScriptに変換してしまったほうが型定義ファイルを作成するのが楽というケースも多いです。

一方、TypeScriptにはBabelのようなプラグインでのエコシステムはあまりないため、その辺の自由度は減ります。 しかし、ライブラリにおいては@babel/preset-env以外のBabelプラグインを導入するケースは、そこまで多くはないと思います。(babel-preset-power-assertなどテストコードへの補助的なものを入れることはあると思います)

ライブラリをTypeScriptにすることにメリットを見いだせる場合は、TypeScriptへ移行してみるのもよいのかもしれません。

リポジトリ

参考

FAQ

  • Q. @babel/preset-typescriptではだめ?
  • A. @babel/preset-typescriptはTypeScriptから型情報を取り除くプリセットで、型定義ファイル(d.ts)を生成したり、型チェックはできません。そのため、ライブラリをTypeScriptに移行する際に、tscを使うことに比べて特にメリットがありません。(どちらにしてもtscの併用が必要になるため、あまり意味がありません)
  • @babel/preset-typescriptはアプリケーション向けだと思います。Babelのエコシステムという柔軟性を取り入れつつ、TypeScriptで書けるメリットを享受できます。