Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

Eight 、TypeScript はじめました

こんにちは。Eight でフロントエンドエンジニアをしている鳥山(@pvcresin)です。 最近は UI デザインとアプリケーションのパフォーマンス改善、ビルド時間の短縮に興味があります。
Eight の Web フロントエンドは JavaScript(JS)で書かれていましたが、2020 年 5 月に TypeScript(TS)を導入しました。 今回は TS 初心者だった私が、どのようなステップを経て TS を学び、実際に導入していったのかについてお話ししたいと思います。

目次

TypeScript

はじめに TypeScript の紹介をします。 TS は Microsoft が開発したオープンソースの静的型付け言語です。 型システムによって、コードの品質を向上させることやコードの理解を助けることができます。 文法的には JS のスーパーセット(上位互換)となっており、JS にコンパイルすることができます。
コンパイル時に型は取り除かれ、ブラウザや Node.js は純粋な JS を実行することになります。 昨今のモダンなフロントエンドの開発環境といえば、ほぼ必須の要素になってきたという印象です。

サンプルコード

本稿では TS の細かな文法については触れませんが、サンプルコードで概形を掴んでみたいと思います。 例えば、数値を 1 つとり、それを 2 乗した値を返す関数は JS では以下のように書くことができます。

// JavaScript
function squareOf(n) {
  return n * n;
}
squareOf(8); // 64
squareOf("Eight"); // NaN

このとき、関数 squareOf"Eight" のような数値以外のものを渡すと不正な値 NaN(Not a Number)を返します。 間違えてこのようなコードを書いてしまっていた場合、JS では実行時に気付くことが多いです。

一方、 TS では以下のようになります。

// TypeScript
function squareOf(n: number) {
  return n * n;
}
squareOf(8); // 64
squareOf("Eight"); // エラー: 型 '"Eight"' の引数を型 'number' の パラメーターに割り当てることはできません。

パラメータ nnumber という型を書くことによって、TS は squareOf が数値のみを渡せる関数であると認識し、 コンパイル時に間違った呼出しをしている箇所をエラーで教えてくれます。 実行前にエラーに気付くことができ、より早くミスを修正できます。
ちなみに TS は型推論機能が優れているため、全ての型を書く必要はありません。 この例で言えば、TS はパラメータの n の型情報を元に squareOf の返り値が number 型であると推論してくれます。

さらに、Visual Studio Code(VSCode) など、TypeScript の Language Service と連携ができるエディタを使うことで、より効率的に開発することが可能です。 例えば、文字を入力するごとに構文解析が行われるため、即座にエディタ上にエラーが表示されます。
また、変数やメソッドに型情報がポップアップ表示されるため、コードを詳しく調べなくても大まかな使い方を知ることができます。
加えて、変数やメソッドの型がわかっているので、そこから利用できるコードが自動的に補完されます。

メリット

これらのメリットをまとめると以下の 3 つになります。

  • 実行する前にコードのエラーチェックができる(テストに近い役割)
  • 既存のコードのドキュメント代わりになり、読解を助ける
  • より賢いコード補完が得られる

これらの理由から、TS を導入することで長期的にみて、コード品質と改善の速度をあげることができると考えました。

TS 導入の背景

Eight の Web フロントエンド は JS ファイルだけでも約 2 千ファイル・15 万行のコードがあり、大規模なアプリケーションと言えます。 数年前のコードが現役で動いていることも珍しくなく、コードリーディングに時間がかかるという問題がありました。 また、一部ユニットテストが足りない部分もあり、慎重に実装していく必要がありました。
そこで TS を導入し、コードを書いている時点でエラーに気付くことができる、型に守られた開発をしたいと考えました。 また、VSCode でフロントエンド開発を行うメンバーが多く、TS の恩恵を受けやすい状況であったことも、TS 導入を後押ししました。

導入の準備

導入を決意したのは新卒で入社してひと月ほどたった頃だったと思います。その時の私のステータスですが、Java や Kotlin などで静的型付け言語に触れてはいたものの、TS を触ったことはありませんでした。
そのため、TS についてはもちろんのこと、TS の導入方法や JS と TS の相互運用性についての知識はありませんでした。 また、Eight 内のエンジニアで TS に精通した人は少なく、ましてや大規模なサービスに TS を導入した経験を持つ人はいませんでした。
これは自分が TS 人材になるしかないと思い、長い旅路になることは覚悟の上で、いくつか段階に分けて準備することにしました。

  1. ドキュメント・書籍・勉強会などの資料で学ぶ
  2. TS で社内ライブラリを新規作成する
  3. 既存の社内ライブラリをフル TS 化する

一つひとつ見ていきます。

1. ドキュメント・書籍・勉強会などの資料で学ぶ

はじめに、TS の言語仕様や機能について体系的に学ぼうと考えました。 最初に、ざっと公式ドキュメントを眺め、大まかな言語体系を掴みました。 その後、書籍「実践 TypeScript 」を題材に社内で輪読会を行いました。

実践TypeScript ~	BFFとNext.js&Nuxt.jsの型定義~

実践TypeScript ~ BFFとNext.js&Nuxt.jsの型定義~

  • 作者:吉井 健文
  • 発売日: 2019/06/26
  • メディア: 単行本(ソフトカバー)

書籍の内容としては、前半で TS の言語仕様について学んだ後、後半でフレームワークと組み合わせるという実践的な内容で、非常に勉強になりました。
輪読会では他の事業部で TS を使ってサービス開発をしているメンバーとも話ができてとても有意義な時間でした。

また、実際に大規模なサービスで TS を使っている方々のノウハウを集めるために、社外の勉強会の資料や TS 導入記事を読みました。 特に TypeScript meetup は、かなり専門的な話が多く学びが多かったです。 (本当は実際に参加したかったのですが、人気のイベントのため参加枠がすぐ埋まってしまい...😇 )
色々な発表資料がありましたが、中でも非破壊 TypeScript は大変参考になりました。
実際に TS を使い倒している方々が日々、何を見て、何を感じているのかを知ることができたのは、非常に大きな収穫だったと思います。

2. TS で社内ライブラリを新規作成する

Eight フロントエンドのリポジトリに導入する前に、何か小さなリポジトリで肩慣らしをしたいと思っていました。 資料にある程度目を通し終えた頃、ちょうど Eight では社内向けのコンポーネントライブラリを新規作成する機会がありました。 採用するライブラリに関する知見が私に多少あったこともあり、設計や実装を任せていただけたので、迷わず開発言語として TS を採用しました。

ここでリポジトリ作成時の TS の設定ファイルや ts-jest を用いたユニットテストについて学ぶことができました。 はじめから TS を採用することによって、実際のデータがとりうる値をより詳細に意識して開発することができました。 また、ライブラリ利用者も型情報を使えるため、ライブラリこそ積極的に TS を導入すべきだと強く感じました。

3. 既存の社内ライブラリをフル TS 化する

新規リポジトリで TS を書くことについては学べたので、次は JS と共存しながら徐々に移行する術を学んでいこうと思いました。 社内向けの通信系ライブラリが JS で書かれており、ファイル数が十数個程度だったので、これを題材に選びました。 このライブラリを勉強がてら徐々に TS に移行し、最終的にフル TS 化しました。

ここでは、型定義ファイルの書き方や徐々に型を書いていく方法について学ぶことができました。 はじめから型を完璧に書くのではなく、型エラーの修正方法がわからなかったら最初はエラーを潰してもいいと思えるようになってから、ずいぶん気持ちが楽になりました。
この方針には賛否両論ありますが、TS に慣れていないメンバーが多いところでは、仕方のないことなのかなとも思います。 もちろんエラーを潰さないに越したことはないのですが、ちょっと頑張って無理だった時は、詰まらずに一旦次に進むのも手だなと感じました。 実際、進めていく中で知見が溜まっていき、後から見直して修正できた型エラーもたくさんありました。
また、型を書いていく中で今までたまたま動いていた部分や非効率な処理を見つけることができ、TS の力を感じることができました。

導入・移行方針の検討

これまでの経験で最低限の TS 力はついたと思うので、実際の導入方針についてフロントエンドを実装しているメンバーと一緒に検討していきました。 Eight では React の JSX 記法を採用しているため .jsx などのファイルがあるのですが、ここでは説明を簡単にするために

  • JS ファイル = .js, .jsx
  • TS ファイル = .ts, .tsx

と定義します。

まず、大まかな方針として、既存のコードに影響を与えないよう、導入時にはファイルの拡張子から TS ファイルを識別し、処理を分けることにしました。 これにより、既存のコードに対しては今まで通りの処理を行い、新しく作成した TS ファイルのみ追加でコンパイルなどの処理が行われることになります。

また、既存コードに関しては一気にファイルを TS に置き換える方針だと何かミスがあったときに手戻りが多いため、徐々に JS から TS に移行していく方針をとりました。 移行時にも、既存のコードのロジックを一切変えないことを最優先して進めていくことにしました。

導入手順

tsconfig.json

まず最初に tsconfig.json の設定からはじめました。 tsconfig は TS のコンパイラ(tsc)の各種設定などを行うことができるファイルです。 ここで実際に TS がどうコンパイルされ、どんなコードになるかを決めていきます。 VSCode はリポジトリ内の tsconfig を元にエラーの表示などを行います。
TS をインストールし、tsc --init で元となるファイルを作成します。 いくつか設定を変更し、最終的に以下のような形になりました。(一部省略)

{
  "compilerOptions": {
    "target": "ESNEXT", /* ESNEXT のコードを出力 */
    "module": "ESNEXT", /* import, export を使ったコードを出力 */
    "allowJs": true,    /* JS ファイルもコンパイル対象に */
    "checkJs": false,   /* JS ファイルの型チェックは行わない */
    "jsx": "preserve",  /* JSX の記法は変換せずそのままに */
    "strict": true,     /* 型チェックなどは厳し目に設定 */
    "baseUrl": "./src", /* import 時に起点となるディレクトリを指定 */
    "paths": {
      "*": ["*"]        /* baseUrl からの相対パスによる import を認識可能に */
    },
    /* 省略 */
  },
  "include": ["./src"]
}

TS をコンパイルすると ESNEXT の JS を出力するように設定しました。
また、"allowJs": true で JS ファイルをコンパイル対象に含め、JS ファイルから推論される型も TS で利用できるようにしました。 型チェックなどは後から厳しくするのは難しいと考え、"strict": true に設定しています。

webpack

Eight フロントエンドではバンドルに webpack を使っているため、以下の 2 つの方針を検討しました。

Eight では JS ファイルにおける ESNEXT から ES5 への変換React の JSX の変換のために既に babel-loader を使っており、はじめにそちらを検討しました。 Babel を既に導入している環境であれば、TS を変換するための preset(@babel/preset-typescript)を追加するだけなので、 手軽に TS のコンパイルを実現することができます。 注意点として、@babel/preset-typescript は型チェックを行ってくれないため、別途 tsc などで型チェックを行う必要があります。
一方、ts-loader は TS をコンパイルするだけでなく、型チェックも行うことができます。 今回は JS と TS で Babel 周りの設定を共通にし、TS ファイルのみ追加の処理を加えるために、ts-loader を採用することにしました。 具体的には以下のように webpack の config を修正しました。

変更前の設定
// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        exclude: /node_modules/,
        use: [
          {
            loader: 'babel-loader',
            options: {
              ...
            },
          },
        ],
      },
    ],
  },
  resolve: {
    extensions: ['.js', '.jsx'],
  },
};
変更後の設定
// webpack.config.js
const babelLoader = {
  loader: 'babel-loader',
  options: {
    ...
  },
};

module.exports = {
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        exclude: /node_modules/,
        use: [
          babelLoader,
        ],
      },
      {
        test: /\.tsx?$/,
        exclude: /node_modules/,
        use: [
          babelLoader,
          {
            loader: 'ts-loader',
            options: {
              transpileOnly: true,
            },
          },
        ],
      },
    ],
  },
  resolve: {
    extensions: ['.js', '.jsx', '.ts', '.tsx'],
  },
  plugins: [new ForkTsCheckerWebpackPlugin()],
};

TS ファイルの場合は最初に ts-loader を通すことで JS ファイルに変換し、そのあとは babel-loader で他の JS ファイルと共通の変換処理を行います。 Babel の設定は変更しないので、既存の JS ファイルに影響がでない形で導入ができます。 tsconfig で "allowJs": true に設定していましたが、実際にコンパイルされるのは TS ファイルのみになります。

また、型チェックを伴う ts-loader での処理は時間がかかるため、transpileOnly: true に設定して高速化を図っています。 同時に、別プロセスで型チェックを行う Fork TS Checker Webpack Plugin を使うことで、開発中もしっかり型チェックを行うことができます。 開発中の型チェックには多少メモリ容量を必要としますが、加えた変更によってどのファイルが影響を受けたかを即座に知ることで開発を加速することができます。

ESLint

次に TS ファイルの Lint(静的解析)の設定を行いました。 TSLint は非推奨なので、 既に JS ファイルの Lint に使用していた ESLint を TS でも使う方向で考えました。 TS で ESLint による Lint を可能にするために、@typescript-eslint を使いました。
そして、以下のように拡張子で TS ファイルかを判別し、既存の ESLint の設定を上書くように設定しました。(一部省略)

// .eslintrc.json
{
  ...
  "rules": {
    ...
  },
  /* TS用のルール */
  "overrides": [
    {
      "files": ["*.ts", "*.tsx"],
      "parser": "@typescript-eslint/parser",
      "plugins": ["@typescript-eslint"],
      "extends": [
        "eslint:recommended",
        "plugin:react/recommended",
        "plugin:@typescript-eslint/recommended",
        ...
      ],
      "parserOptions": {
        "sourceType": "module",
        "ecmaFeatures": {
          "jsx": true
        }
      },
      "rules": {
        ...
      }
    }
  ]
}

あとは eslint src --ext js,jsx,ts,tsx のように、拡張子を指定してあげれば OK です。
ちなみに型情報に基づく Lint を行いたい場合、parserOptions.project に tsconfig.json のパスを書く必要がありますが、 ESLint の動作が遅くなるため注意が必要です。

Prettier

次にコードのフォーマッタ、Prettier を TS ファイルでも動くように設定しました。 以前から Eight では、Prettier と競合する ESLint のルールを無効化する eslint-config-prettier を導入していたため、それを TS でも有効にする設定を追記しました。

"extends": [
  ...
  "prettier",
  "prettier/react",
  "prettier/@typescript-eslint"
],

あとは Prettier を呼び出すときに prettier 'src/**/*.{js,jsx,ts,tsx}' --write のように、拡張子を指定してあげれば OK です。

ユニットテスト

次にユニットテストの設定です。 Eight では Mocha というテストフレームワークを使っています。 そして、webpack を用いてテストに必要なファイルをあらかじめコンパイルしてくれる mochapack というツールと併用しています。
これにより、先に挙げた webpack の設定で、テストファイルについても TS を解決できるようになっているというわけです。 よって、TS ファイルをテストの対象とすることと、テストファイル自体を TS で書くことが同時に可能になりました。 ここがすんなりいったのは、ありがたかったです。

CI

CI 上で TS の型チェックを行うために、設定ファイルを更新しました。 型チェックには tsc --noEmit コマンドを使用しました。 このコマンドは、コンパイル処理を行いつつもファイルを出力しないため、型エラーのみをチェックしたい時に使えます。
実際の作業としては、Circle CI の job に上記のコマンドを実行する step を追加するのみです。 これで、PR がマージされる際は、型エラーがない状態を担保できます。

導入完了

ここまでで、TS ファイルをコンパイルすることが可能になりました。
リポジトリにいくつか TS ファイルを追加し、数人のエディタ上でコード補完や、型エラー、Lint エラー、コードの自動整形などがうまく動いていることを確認しました。 そして、PR を出して CI での型チェックがうまくいっているかを確認し、最後に開発環境へのデプロイがうまくいくかを確認しました。
問題がなかったため、一旦ここでリリースを行いました。 本番環境でのデプロイにも問題なく、しっかり TS ファイルがコンパイルされていることが確認できて一安心しました🎉

移行手順

ここまでで TS の導入はひとまず完了しましたが、今回は既存の JS ファイルから TS ファイルに移行していく部分についても触れておこうと思います。 先の方針で挙げた通り、ロジックを一切変えないことを最優先としました。

1. ファイルの拡張子を変更する

まず最初にファイルの拡張子を変更し、tsc --noEmit --watch などで型エラーが出ている部分を確認します。

2. ライブラリの型定義をインストールする

型定義がないライブラリは import している部分でエラーが出るため、 DefinitelyTyped で型定義を探してインストールしました。
それでも足りない場合は型定義ファイル(.d.ts)を作成しました。

3. わかる範囲で型を書く

はじめから厳密にすべての型を書くのは難しく、既存のコードがそもそも型を付けづらい形になっている場合も多いのですが、時間の許す範囲で頑張って型を書いていきました。

4. 残った型エラーを潰す

最後に残った型エラーを潰していきます。 型がわからなかった場合にひとまずつける型として、Todo という型を用意しました。

declare type Todo = any;

ある程度移行が完了した時点で、あとから修正するために any に別名をつけています。 既存のプロジェクトからの移行で完全に any を消すことはなかなか難しいと考えており、 型がわからなかった場合の泣きの any(Todo)なのか、想定通りの any なのかをあとで判別しやすくするためにこういった方針をとっています。 完全に any をなくせそうならいらないと思います。

また、エラーを潰す際には // @ts-expect-error コメントも使用しました。 これは、次の行にエラーがあることを保証するコメントで、エラーが消えた場合は tsc がこのコメントは必要ないと通知してくれます。 例えば、JS から import した関数などのエラーを潰す場合に // @ts-expect-error コメントを使うと、 その JS ファイルを TS に移行した際にエラーがなくなる場合があるため、有用です。
ちなみに似たようなコメントとして // @ts-ignore コメントがありますが、 これは次の行にエラーがあってもなくても無視するため、必要ない場合に気付くことができる// @ts-expect-error コメントを使用しました。

5. TS の範囲を広げていく

上記の手順 1~4 を繰り返し、PR を出し続けていきます。 通信などのデータ構造に関わる部分やよく使われる関数などから行うと、型の恩恵が得られやすいです。 根気のいる作業なので、チームでしっかりとやっていくという合意をとった方が良いと思います。

TS を導入してみて

TS 導入を決意してから実際に導入するまで、約 1 年ほどかかりました。 Eight フロントエンドへの TS 導入作業自体は個人のサイドタスクとして進め、3 ヶ月ほどで終わりました。 途中で導入方針を大きく変えるなど紆余曲折あったのですが、最終的には導入することができてよかったと思っています。 「もっとこうしたら楽に導入進められるよ!」「ここは間違えているよ!」等あれば優しく教えていただければ幸いです。

現在は、新規のコードを TS で書き、既存の JS のコードはチームのメンバーと一緒に空いた時間で少しづつ移行を行っています。 TS への移行に関しては ts-migrate といったツールも出てきており、 より敷居が下がっていくのではないかと思います。
TS 導入当初は型を書くのに手間取ってしまい、JS で開発していた時に比べて開発速度が落ちていましたが、 現在では体感で同じぐらいになってきています。 何より、どういうデータが来るのかが明記されているという安心感は大きいです。 また、既存の実装のよくない部分も徐々に浮き彫りになってきており、移行を続ければこれらがじわじわと効いてくるのではないかと期待しています。

まとめ

今回は、Eight の Web フロントエンド に TS を導入した話をしました。
TS はモダンフロントエンドにおける必須の要素となってきており、特に Eight のような大規模なプロジェクトでは力を発揮します。 導入には様々なステップを経る必要がありますが、TS 初心者の私でもできたので、できないことはないと思います。
未来への投資として型と向き合っていきましょう!


buildersbox.corp-sansan.com

buildersbox.corp-sansan.com

buildersbox.corp-sansan.com

© Sansan, Inc.