はじめに

現在ピグ事業部の主力サービスである「ピグパーティ」でサーバーサイドエンジニアをしております新卒2年目の川口です。普段はSRE関連の業務を主とし、サーバーサイドの業務効率改善や、システムの運用保守、時には機能の開発や修正なども担当しております。

ピグパーティは、2015年にiOS/Androidでリリースされたアバターコミュニティアプリで、サーバーサイドはNode.js(JavaScript)+MongoDBを採用しています。

ピグパーティの紹介画像

 

ピグパーティではこれまでにプライベートクラウドからGCPへの移設、Kubernetes(GKE)の採用など、技術的負債の排除や新技術の採用などを積極的に行ってまいりました。今回は、サーバーサイド開発時の大きなボトルネックになっていた、型がわからないことに起因する複数の問題を解決するために、TypeScriptを導入することにしました。その結果、システム規模に対して少ない工数で最大限にTypeScriptの恩恵を得ることができたので、本記事では対応方針とその具体的な手順を紹介します。

システムの規模や複雑さが特殊ということもあり、私たちのTypeScriptへの移行の方法は一般的なts-migrateを利用した漸進的な移行方法とは少し異なります。そもそも私たちの「TypeScript移行」という言葉の定義は、「TypeScriptを導入することによって型の恩恵が十分に得られている状態にすること」であり、他のプロジェクトが指すTypeScript移行とは異なるかもしれません。Node.js+JavaScriptで長期間運用をしていてTypeScriptの導入方法を色々と検討をされている方へ、事例の一つとして少しでも参考になれば幸いです。

なお、この記事ではTypeScriptについての基本的な知識、導入方法などの説明は省略します。

経緯

ピグパーティでは、ステートフルなリアルタイム通信をはじめとして、クエスト、パーティ(ユーザーのお部屋で他のユーザーと交流をするための、ピグパーティにおける主要な機能)、人狼ゲームなど、仕様が非常に複雑かつ開発や修正が高難易度な実装が数え切れないほど含まれるシステムを、常に早い開発サイクルで6年以上もの長期間にわたって運用し続けてまいりました。その結果、ソースコードは100万行にまで肥大化し、機能やコードの属人化、複雑化、およびそれに伴う不必要な調査やバグの対応が開発時の大きなボトルネックになっておりました。

大半の関数やメソッドに対してJSDocが記述されていましたが、パラメータに渡されている@param {Object}はどんなプロパティを持つのか、およびそのオブジェクトのプロパティはどこのロジックがどこでどのようにして追加されるのかが分かりにくい状態でした。また、MongoDBのドキュメントの型も一部を除き正確に記述されていない状態でした。

その結果、オブジェクトを扱う時はその生成過程の調査から始まるだけではなく、誤ったプロパティへのアクセスや代入、nullやundefinedチェックの不足、数値を入れるはずの変数に文字列やオブジェクトが入っているなど、型がわからないことに起因するバグの調査や修正が必要でした。機能開発を行うエンジニアの方達が本質的なビジネスロジックの構築に集中できるようになるには、それらを減らす必要があり、そのためにTypeScriptを活用しようと決めました。

TypeScript移行を進める上での課題

私たちが今回TypeScript移行を進めていくための課題は以下の2点でした。

オブジェクトの多くは型情報が記述されていない

前述の通り、MongoDBのドキュメントの型を含む、ソースコードのあらゆるところに存在するオブジェクトの大半は型情報が記述されていませんでした。

システム全体をTypeScript化していくための十分な工数を割くことが現実的に不可能

100万行のシステム全体をそれなりのクオリティ(高い品質で型付けされており、any型や@ts-ignore、型アサーションなどの利用が少ない状態)でTypeScript化するためには最低でも数年はかかるでしょう。またピグパーティでは常に新規開発ラインが複数稼働している状態のため、メンバーの中でTypeScript化に十分な時間が割けるのは私一人でした。そのため、最小限の工数で最大限にTypeScriptの恩恵を得るための工夫が必要になります。

 

最終ゴール

今回のTypeScript移行の最終ゴールは以下の通りです。

  • 開発のボトルネックが改善できている状態
    • 開発時に必要な調査やバグの修正が最小限であること
    • 型がドキュメントのように機能していること
  • システム内でよく参照・改修される重要なロジックの型安全性が保障されている状態
  • 新規開発時にTypeScriptを利用できる状態

 

ピグパーティのソースコードのアーキテクチャ

ピグパーティのソースコードは下図のようなアーキテクチャを採用しており、今回はこのシステムをTypeScript化しました。

ピグパーティのソースコードのアーキテクチャ図

ピグパーティのシステムは大雑把に分けると、core(システム内での共通処理)、web(APIサーバー)、chat(ステートフルなリアルタイム通信系)の3つのリポジトリから構成されています。(管理画面やバッチ処理などのコンポーネントもcoreを参照しておりますが、ここでは省略します。)

ピグパーティではレイヤードに近いアーキテクチャを採用しており、service層でドメインの知識を表現・実装し、それらおよびreq/resをcontroller層から制御しています。service層はcore、chat、webに含まれています。また、chatやwebなど、クライアントサイドと直接通信を行うコンポーネントのリポジトリのみcontroller層を持ちます。

RedisクライアントやMongoDBのドライバーなど、サードパーティのライブラリは使いやすい形式のラッパーをnpmパッケージとして別リポジトリで運用し、coreからexportします。typesは後述する今回追加した型(型エイリアスの宣言)を配置しているディレクトリです。

ピグパーティではパフォーマンス向上を目的として、ショップ情報やガチャ情報など、高頻度でアクセスされるマスターデータなどの固定データを「キャッシュ」とよばれるオブジェクトとして各サーバーにオンメモリで配置しております。以降「キャッシュ」はこのキャッシュを指します。管理画面から「キャッシュ更新」の作業を行うと、coreのcache_updaterの処理がよばれ、各サーバーに最新のキャッシュがデプロイされます。

 

方針

以下の方針で進めました。

開発で重要な処理・機能や頻繁に編集・参照されるファイルを優先的にTypeScriptに書き換え、それ以外のファイルはTypeScriptに書き換えない

システムがとても巨大とはいえ、アプリ開発時などに頻繁に編集・参照されるファイルはシステム全体のうちたったの1割にも及ばず、実際には不要になった機能の残骸やデッドコード、長年改修が加えられておらず今後も改修を加える可能性の低い機能などがその多くを占めております。それらのコードをTypeScriptに書き直しても工数に対して得られる恩恵が少ないため、基本的にはそれらには手をつけない方針で進めます。一方で開発でよく編集・参照されるものや、ビジネス的に重要度の高い機能などは優先的に、可能な限り精度高くTypeScriptへ書き換えを行います。

例えばピグパーティでは、controller層やservice層、その中でも特に、ユーザー系の処理、各エリアの汎用的な機能、現在特に注力しているパーティ機能に関連する処理を優先的にTypeScriptに書き換えをしました。

ただし、TypeScriptで書き換えた処理から、重要度が低く改修頻度の少ないようなjsファイルに記述された関数やメソッドを参照しているケースもあります。その場合、必要に応じて後述する方法でjsファイルにも型をつけます。

ts-migrateは使用しない

ts-migrateはjsファイルをtsファイルに自動で変換してくれるツールです。TypeScript移行をするときは、ts-migrateを使用して全体的にもしくは部分的にファイルをtsファイルに一括変換し、any型や@ts-expect-errorが含まれるファイルを少しずつ型付けを行う方法で進めるプロジェクトが多いような印象です。

しかし、今回のTypeScript移行では以下の理由から、ts-migrateは使用する必要はありませんでした。

  • 今後多くのファイルの拡張子をtsに変更するつもりが一切ない
  • TypeScript化をするファイルは重要なファイルのみなので、私が一人でファイル単位で一気に型付けを進める
  • any型や@ts-expect-errorをたくさん含んだ状態でTypeScript化されたファイルが存在すると以下のデメリットがある
      • きちんとした型付けがされたTypeScript化がどこまで進んでいるのかがわかりにくくなる
      • @ts-expect-errorのコメントが大量に生成される分可読性が低下する
      • 新規開発時やコードの修正時にもany型や@ts-expect-errorを許容しがちになりそう

今回のTypeScript化は必要最小限の工数で開発効率の向上をさせることが目的であり、jsファイルをtsファイルに変更することではありません。そのためts-migrateを使用する必要はありませんでした。ts-migrateはTypeScript化を効率よく行うためには非常に優れたツールなので、TypeScript移行を行う予定であれば使用の検討をすることをお勧めします。

 

手順

今回のTypeScript移行の具体的な手順は以下の通りです。

  • 依存するnpmパッケージをTypeScript化する
  • プロジェクトにTypeScriptを導入する
  • システム全体で利用される共通の型エイリアスを宣言するディレクトリを導入する
  • オブジェクトの依存の順番に型付けおよびTypeScriptへの書き換えを進める
  • jsファイルに型をつける
  • 勉強会を開催する(並行)

以下に各手順の詳細を説明します。

依存するnpmパッケージをTypeScript化する

まず初めに前述のシステムアーキテクチャ図の最下層に当たるnpmパッケージ群をTypeScript化しました。ここは以下のような理由があるため、一番最初に着手かつ全てのファイルをTypeScriptに変更しました。

  • 多くのロジックの依存先になるので初めに着手することによる恩恵が大きい
  • 結合度が低く最も簡単にTypeScript化が可能
  • 適度な大きさに分割されているためそれぞれ工数がかからない

ただし、パッケージの利用箇所でTypeScriptの恩恵を受ける必要がない場合や、工数をかけるための十分な時間がない場合は、型定義ファイル(.d.ts)を作成するだけでも十分だと思いました。

プロジェクトにTypeScriptを導入する

まずはプロジェクトにTypeScriptを導入し、TypeScriptコンパイラーによってコンパイルされたファイルで動作する状態にします。TypeScriptのバージョンは、導入開始時点で最新の4.3を利用しています。TypeScriptの導入方法は、typescript@types/hoge系のinstall、tsconfig.jsonの追加など、一般的な手法と変わりませんが、今回のtsconfig.jsonのポイントを少しだけ説明します。

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "allowJs": true,
    "checkJs": false,
    "declaration": true,
    // ...
  },
  // ...
}

strictは型を厳しくチェックするためのさまざまな設定をいくつかtrueにしてくれる設定ですが、tsファイルに変更するものは可能な限り安全かつ精度高く型を付けたかったため、初めからtrueにしました。allowJsはjsファイルをコンパイルの対象に含めるため、今回のTypeScript移行ではtrueにする必要があります。checkJsはJSDocを利用してjsファイル内でも型チェックをするかどうかのフラグですが、修正する予定のない膨大な量のjsファイルが型エラーになってしまうため、今回のTypeScript移行ではfalseにする必要があります。ちなみに同様の理由で@ts-checkも今回のTypeScript移行では一切使用しません。declarationは、型定義ファイル(.d.ts)を生成するために必要なフラグです。TypeScript3.7以降では、前述のallowJsとの共存が可能になりました。jsファイルのJSDocに型をつけることによる恩恵を受けるために、今回のTypeScript移行ではtrueにする必要があります。

今回のTypeScript化では、まず初めにcoreにTypeScriptを導入し、coreのTypeScript化が十分に進んだ段階でchatやwebにも同様に導入しました。

システム全体で利用される共通の型エイリアスを宣言するディレクトリを導入する

coreのリポジトリのトップレベルの階層に、システム全体で利用される共通の型エイリアスを宣言するためのtypesディレクトリを導入しました。
ここでは以下の3点を意識しました。

  • 全ての型へ、最小限のオーバヘッドで迷うことなくアクセスできるようにする
  • 宣言する型エイリアスにはJSDocを付与する
  • システム内部で意味を持つ変数にはそれぞれ型エイリアスを宣言しておく

全ての型へ、最小限のオーバヘッドで迷うことなくアクセスできるようにする

今回のような大規模なコードであれば当然大量の型の宣言が必要になります。
そのため、型を配置するディレクトリは以下の要件を満たす必要があると考えました。

  • 利用したい型の数に関わらず、import、requireが一行で済む
  • 新たな型を宣言するときに配置場所に迷わないような粒度で分割されている
  • 直感的に型にアクセスできる

ディレクトリ設計の前に、どのような書き方で型へアクセスできると良いのかを考える必要があります。

例えば私たちのプロジェクトでは、型へのアクセス方法は以下のような形式が理想だと考えました。


import {
  // npmパッケージからexportされる型や、coreで定義される固有のロジックの型の集合
  Core,
  // キャッシュオブジェクトの型の集合
  Cache,
  // 各コンポーネントの設定値を格納するオブジェクトの型の集合
  Config,
  // MongoDBに格納されるオブジェクト(ドキュメント)の型の集合
  DB,
  // 汎用的な型の集合
  Util,
  // ピグパーティの固有(ドメイン知識の表現)の型の集合
  Pigg,
} from 'path/to/types'

// 型へのアクセス例

// キャッシュオブジェクトは Cache.Foo の形式でアクセスできる
/** 「ショップ」のキャッシュオブジェクト */
type ShopCache = Cache.Shop

// DBに格納されるオブジェクトは DB.Foo の形式でアクセスできる
/** Userコレクションのドキュメントの型 */
type User = DB.User

// ピグパーティ固有の型は Pigg.Foo.Bar(.Baz. ...)の形式でアクセスできる
/** ユーザーIDの型 */
type UserId = Pigg.User.UserId
/** アイテムのレア度 */
type ItemRaritiy = Pigg.Item.Rarity

// 汎用型は Util.Foo の形式でアクセスできる
/** オブジェクトのvalueのユニオン型を取得する型 */
type ValueOf<T> = Util.ValueOf<T>

これを実現するためにピグパーティで導入したtypesディレクトリは以下の通りです。

types
│
├── util.ts // 汎用的な型を宣言
│
├── db // MongoDBに格納されるオブジェクト(ドキュメント)の型を宣言
│ ├── user.ts
│ ├── ...
│ └── index.ts
│
├── cache // キャッシュオブジェクトの型を宣言
│ ├── shop.ts
│ ├── ...
│ └── index.ts
│
├── pigg // ピグパーティの固有(ドメイン知識の表現)の型を宣言
│ ├── user.ts
│ ├── item.ts
│ ├── ...
│ ├── util.ts // ピグパーティ固有の汎用型を宣言
│ ├── ...
│ └── index.ts
│
├── config // 各コンポーネントの設定値を格納するオブジェクトの型を宣言
│ ├── web.ts
│ ├── chat.ts
│ ├── ...
│ └── index.ts
│
├── core.ts // npmパッケージからexportされる型やcoreで定義される固有のロジックの型をラップした型を宣言
│
└── index.ts // このディレクトリに含まれる全ての型をexport

それぞれのディレクトリ・ファイルに定義された型を、以下のように直感的にアクセスできるような名前を付与してexportするようにしています。

// types/index.ts
export * as Util from './util'
export * as DB from './db'
export * as Cache from './cache'
export * as Pigg from './pigg'
export * as Config from './config'
export * as Core from './core'

UtilCoreなど、Foo.Bar形式でアクセスできるものは最もシンプルで以下のような設計になっております。

// types/util.ts
/** オブジェクトのvalueのユニオン型を取得する型 */
export type ValueOf<T> = T[keyof T]

...

DBCacheConfigなど、Foo.Bar形式でアクセスできるが、Barの種類が多くファイルを分割したいものに関しては以下のような設計になっております。

// types/db/index.ts
export * from './user'
...
// types/db/user.ts
import {Pigg} from 'path/to/types'

/** Userコレクションのドキュメント型 */
export type User = {
  /** ユーザーID */
  _id: Pigg.User.UserId
  /** ユーザー名 */
  name: string
  ...
}
...

Piggのように、Foo.Bar.Baz形式で、Barの種類が多くファイル分割したい場合は以下のような設計になっております。

// types/pigg/index.ts
export * as User from './user'
export * as Item from './item'
...
// types/pigg/item.ts
import {Util} from 'path/to/types'
import {CONSTANT} from 'path/to/core'

/** アイテムのレア度 */
export type Rarity = Util.ValueOf<typeof CONSTANT.ITEM.RARITY>

...

ただし、上記はTypeScript移行がそれなりに進んでいる成熟した状態での例です。手順としては、先にディレクトリとファイルだけ大まかに分割してしまい、TypeScript移行の途中で必要になり次第型エイリアスやファイル、場合によってはディレクトリを追加していく方針で進めていきました。

アーキテクチャ図にある通り、chatやwebにも同様にtypesディレクトリを導入します。
しかし、それらのtypesはcoreからtypesをimportし、各コンポーネントで必要最小限の型のみ追加します。

// chatのtypes/index.ts

import * as core from 'core'

export import DB = core.types.DB
export import Pigg = core.types.Pigg
export import Cache = core.types.Cache
export import Util = core.types.Util
export import Config = core.types.Config
export import Core = core.types.Core

// chatでのみ使用する型を宣言
export * as Chat from './chat'

宣言する型エイリアスにはJSDocを付与する

型をドキュメントのように機能させる目的で、宣言する型エイリアスには可能な限りJSDocを付与するようにしました。とても地味ですが、特に今回のように大量の型宣言が必要となる場合、システムの属人化が激しい場合などは非常に役に立つと思います。VSCodeなど、エディタによってはマウスオーバーによってマークダウンのような形式で表示してくれるので非常に便利でした。

/**
 * Hogeの型
 * - A
 * - B
 *   - C
 * @see {@link hoge}
 */
export type Hoge = {
  /**
   * foo
   * @example 'foo'
   */
  foo: string
  /**
   * bar
   * @default 123
   * @deprecated
   */
  bar?: number
  /** baz */
  baz: Baz
}

JSDocがエディタで表示されている様子

システム内部で意味を持つ変数にはそれぞれ型エイリアスを宣言しておく

たとえstring型やnumber型が入る変数であっても、システム内部で意味を持つものはそれぞれ型を宣言しておきます。これは一見冗長にも思えますが、変数が何を表しているのかということを記述できるだけでなく、TypeScript移行を進めていく過程で型を変更したくなった場合に修正箇所を追いきれないという問題を防ぎます。

例えばピグパーティでは、「エリアコード」とよばれるエリアの識別子となる文字列は、AreaCodeという名前のstringの型エイリアスを宣言しました。

// types/pigg/area.ts
/**
 * エリアコード
 * @example 'area-example_1234'
 */
export type AreaCode = string

たとえstringであっても、エリアコードを意味する箇所は全てAreaCode型を付与します。

/**
 * ユーザーIDからユーザーのお部屋のエリアコードを生成する
 * @param {Pigg.User.UserId} userId ユーザーID
 * @return {Pigg.Area.AreaCode} エリアコード
 */
const generateAreaCodeByUserId = (userId: Pigg.User.UserId): Pigg.Area.AreaCode => {
  // ...
  return areaCode
}

実際にこのAreaCode型は途中でstring型からTemplate Literal Typesを使用した型に変更する必要が出てきたのですが、型エイリアスの宣言を微修正するだけで済みました。

オブジェクトの依存の順番に型付けおよびTypeScriptへの書き換えを進める

これ以降は以下の手順でTypeScript化を進めていきます。ただし、ここでいうオブジェクトとは、{id: 123, name: 'foo'}のような「JavaScriptのObjectクラスのインスタンス」のことを指します。また、ここでいう依存とはオブジェクトのプロパティを参照していたり、オブジェクトからオブジェクトを生成している場合などの元のオブジェクトに対して発生する依存を指します。

1. 全てのオブジェクトの依存元となるオブジェクトに対して型付けをする

2. オブジェクトの依存関係の順番に以下を繰り返す
  2.1. TypeScript化したいファイルで使用されるオブジェクトの型を何かしらの方法で予測し、適切な場所に可能な限り正確に記述する
    - any型は可能な限り使用しない

  2.2. TypeScript化したいファイルの拡張子を.tsに書き換え、JavaScriptからTypeScriptの記述に変更する
    - 手順2.1で作成した型を当てはめる
    - 型エラーになった箇所は型とロジックのどちらが正しいか確認し、修正

  2.3. 書き換えたtsファイルが、jsファイルに記述された関数やオブジェクトなどに依存している場合
    - そのjsファイルをTypeScriptに書き換える優先度が高い場合、TypeScriptに書き換える(手順2.1に戻る)
    - そのjsファイルをTypeScriptに書き換える優先度が低い場合、必要に応じてJSDocに型付けを行う

オブジェクトの依存の順番にしたがってTypeScript化を進める理由は、型がわからないオブジェクトが存在することによってそれに依存している変数の型もわからなくなるといった問題を防ぎ、TypeScript化が進めやすくなるためです。

例として、ピグパーティでは以下のように進めていきました。まずは下図にピグパーティにおけるオブジェクトの依存関係の概要を示します。

ピグパーティにおけるオブジェクトの依存関係

型付けは、この図で示す赤丸の番号の順番で行いました。

ここに示すように、controller層やservice層のオブジェクト、キャッシュオブジェクトは全てDBのオブジェクトに依存しています。そのため一番最初にDBの型付けが必要で、ここの作業が手順1に相当します。蛇足ですが、ピグパーティでは一部マスターデータを格納するコレクションを除き、MongoDBに格納されているドキュメントの正確な型情報がどこにも記述されていなかったため、DBの型を推測するためにMongoDB公式のGUIツールであるMongoDB CompassのSchema分析機能を使用しました。

次に依存されやすいオブジェクトがキャッシュオブジェクトなので、キャッシュオブジェクトを生成する処理であるcoreのcache_updaterの処理のTypeScript化と共にキャッシュオブジェクトを型付けします。その次に、coreのservice層のオブジェクトが依存されやすいので、coreのservice層のTypeScript化(優先度の高いもののみ)とともにそれらのオブジェクトを型付けします。それ以降も同様にオブジェクトの型付けと優先度の高いファイルのみTypeScript化を行います。ここの作業が2の繰り返しに相当します。

jsファイルに型をつける

TypeScriptからは、tsファイルだけでなくjsファイルを読み込むことも可能です。TypeScript化を進めていく途中でjsファイルの処理をimportしなければならない場合も存在します。そのjsファイルの使用頻度が低かったり、あまり重要ではない場合は、TypeScript化せずにjsファイルの必要な箇所に型をつけるだけで先に進みます(前の手順でいう2.3に相当します)。

読み込まれるjsファイルの関数や変数などに対してJSDocで型をつけることで、読み込む側でコンパイル時に型チェックを行うことができます。ただし、checkJsがfalseなのでjsファイルに対しては型チェックをしないことに注意してください。

JSDocを利用してTypeScriptの型の多くを表現することが可能です。例えば@templateタグを使用することでジェネリック型を表現することも可能です。JSDocでTypeScriptのような型をつける具体的な方法に関してはJSDoc Referenceに記述されています。ただし、TypeScriptで表現できる全ての型をJSDocだけで記述することは不可能である場合や、JSDocが複雑になりすぎてしまうなどの場合も存在します。その場合はtsファイルに表現したい型などを宣言し、それをjsファイルのJSDocで利用するなどの工夫が必要になる場合もあります。

下図にピグパーティでのシステムアーキテクチャでTypeScript化する時の例を示します。

ピグパーティでのシステムアーキテクチャでTypeScript化する時の例

図のファイル間の矢印は依存を表しています。例えばbarという機能と、そのファイルが特に参照される回数が多かったり、ドメインの中での重要度が高かったと仮定します。その場合はbar関連のファイルは全てtsファイルに、高い精度で書き換えを行います。ただし、controller/bar.tsは、あまりTypeScript化の優先度が高くないservice/baz.jsを参照しており、利用したいgetBaz関数にJSDocに十分な型がついていなかった場合は、必要に応じてtypesに宣言した型をrequireして型付けを行います。

// service/baz.js
const {Pigg, Util} = require('path/to/types');

/**
 * bazを取得する
 * @param {Pigg.Baz.BazId} bazId - bazのID
 * @param {Util.CallbackWithResult<Pigg.Baz.Baz>} callback - コールバック
 */
const getBaz = (bazId, callback) => {
  // ...
  callback(null, {bazId, name})
}
exports.getBaz = getBaz

これにより、service/baz.jsgetBazに一切修正を加えずに型がついているように見せることができるため、controller/bar.tsでは型安全に、最小限の工数でTypeScript化を進めることができます。

勉強会を開催する (並行)

新規機能開発や改修時にTypeScriptを利用してもらうためには、チーム全員がTypeScriptに対して十分に知識を持っている必要があります。そのため、TypeScript移行の作業と並行して勉強会を開催しました。勉強会は輪読会形式で行い、書籍はO’ReillyのプログラミングTypeScriptを採用しました。各章を1-2回に分けて毎週開催するくらいでちょうど良いボリュームでした。

 

結果・わかったこと

型があることでオブジェクトの構成など調査をする必要がなくなり、開発効率やソースコードを読む速度が飛躍的に向上しました。

また、厳密にTypeScript化を実現できたファイルのほとんどで大量のバグを見つけることができました。存在しないプロパティへのアクセスやnullチェックの欠如、number型が入るべき変数に関数が入っていたりなど、人間のコードレビューでは見逃しそうだけどTypeScriptでは検知できるようなバグが、体感としては少なくとも100行に1箇所程度は存在しておりました。

ただし、当然はじめからTypeScriptに移行する前提での設計にはなっていなかったため、厳密にTypeScriptに書き換えるために複雑な型定義やJSDocを追加するなど、シンプルさを犠牲にした箇所も一部存在しました。今回は型の厳密さ重視で進めましたが、開発効率の向上という観点から見ると、型の厳密さとシンプルさの良いバランスを探す必要がありそうな印象でした。現在ピグ事業部では新規プロジェクトをNode.js+フルTypeScriptで設計しているのですが、どのような設計にしたらシンプルに記述できるのか、TypeScriptで書きやすいのか、などの知見もピグパーティを反面教師として得られたため結果として良い機会になりました。

ピグパーティでは、重要な処理が特定のファイルに集中しているということもあり、ファイル単位でTypeScript化を進めていく手順を採用しました。しかし、大きなファイルなどのうち一部の処理のみをTypeScript化したい場合や、関数やメソッド単位でTypeScript化を進めていきたい場合、全てのファイルをTypeScript化させたい場合などは、ts-migrateを使用する方が進めやすそうだと感じました。

ちなみに今回のTypeScript化で追加したTypeScriptのコードは約8万行でした。現在tsファイルはシステム全体の1割にも満たないですが、機能の開発や修正時にはTypeScriptの恩恵を十分に享受することができています。
システム全体のコードの行数とtsファイルのコードの行数の画像

コードの行数にnode_modulesやTypeScriptのコンパイル成果物などは含まれておりません。計測に使用したツールはVS Code CounterというVSCode拡張機能です。

 

まとめ

私たちのように長期間にわたって大規模で複雑なシステムを運用している場合、システム全体をTypeScriptに書き換えをしても工数に対する十分な恩恵を得られないケースも一定数存在すると思います。また、システム全体をTypeScriptに書き換えるのはハードルが高いだけでなく、事業優先度的にも大きな工数をかけて着手できないケースもあるでしょう。そのような場合は、TypeScriptの型でオブジェクトやコードをドキュメント化させつつ、ソースコードのうち重要な一部分のファイルのみを集中的にTypeScript化をするだけでも十分に開発効率が上がるかもしれません。

 

宣伝

ピグ事業部では、現在新規プロジェクトを開発中です!一緒に新規・既存プロジェクトを開発してくれるUnityエンジニアやサーバーサイドエンジニアを大募集中です!ピグ事業部は今回のTypeScript移行のようにレガシー技術に対して真剣に向きあう文化があり、新規技術の導入に関しても個々の裁量権が大きいため、エンジニアが成長できるような環境が整っていると思います。もし興味のある方はぜひこちらをご覧ください!

 

2020新卒入社のサーバーサイドエンジニアの川口です。 現在はピグ事業部の主力サービスである「ピグパーティ」でSRE関連の業務、サーバーサイドの業務効率改善、機能開発などを行っています。