はじめに
なぜ Javascript から Typescript に置き換えたのか?
まず、Typescript のメリットは何か?
この記事などで Typescript の旨味を伝えてくれています。
TypeScript の導入で開発現場はどう変わる? Sansan の事例に見るメリットとコスト
自分がフロントチームとしてアサインした時、開発している Web アプリケーションは運用開始してから 4 年程経っていて、人も入れ替わりの時期でした。
4 年も経っているので仕方がないことなのですが、機能を担当した人しかわからない部分や、ドキュメントに記載されていないような暗黙知みたいなところが少しづつ出てきているような感じでした。
これらは言わば「属人化」という、人に依存するリスクだと思います。
自分が新規参画者側ということもあったため、このアプリケーションの属人化をなるべく排除していきたいというモチベーションがありました。
そこで、まずはコードを静的型付け言語にし、型を強制し暗黙知を減らそうということで Javascript から Typescript への移行の活動を始めていきました。
React アプリケーションで自分は作業していますが、Javascript->Typescript への移行作業自体はフレームワークに依存しないので、他のフレームワークでも応用できると思います。
作業実績
-
-
Files changed
の数が実際より多いことになっていますが、随時 master ブランチから別ブランチの修正を取り込んでいたため多く表示されています。
-
-
作業用ブランチと master ブランチの差分から(
git diff --stat
)項目 実績 修正ファイル数 887 ファイル 修正コード行数 51477 ステップ 対応にかかった期間 約 2 ヶ月半 対応人数 1 人
環境
- React(CRA)
- Javascript
- npm
方針
- 当たり前の前提として、既に動いている機能は壊さない
- Javascript -> Typescript へ最短で置き換えて、無事起動するところまでを第一の目標とする
- any 型を許容する(後の改修や機能追加で型を順次付与していく想定)
作業の概要
コンパイルエラーを解消し、最低限動作するまで
- ts-migrate を使って全体的に Typescript へ移行する
- ts-migrate で出力された指摘を手作業で修正し、指摘コメントを削除していく
- 依存ライブラリに必要な
@types
を install していく - 型が無くてエラーになっているところに対して any で型を付与していく
※ここまで終わったら
- この時点で master にマージしても問題はない
- マージする場合は既存機能が問題なく動作するかしっかり確認する!
- 一旦マージしてチーム全員で型品質を上げることに取り組んでいくか、ある程度品質が上がるまで別ブランチで作業するかは話し合って決める
最低限動作することを確認した上で、Typescript としての品質を上げていく
- API との IF の型を定義して適用する
- any 型を付与したところを順次型付けしていく
作業内容
ts-migrate を使って全体的に Typescript へ移行する
ts-migrate とは
ts-migrateは Javascript から Typescript への移行をサポートしてくれるツールです。
以下のステップで構成されます。
-
[Step 1 of 4] Initializing ts-config for the "src"...
- tsconfig.json を作成します
-
[Step 2 of 4] Renaming files from JS/JSX to TS/TSX and updating project.json\...
- プロジェクト配下の
js/jsx
拡張子のファイルをts/tsx
拡張子ファイルに変換します
- プロジェクト配下の
-
[Step 3 of 4] Fixing TypeScript errors...
- ts-migrate の方で対応できる Typescript エラーを自動で解消します
-
[Step 4 of 4] Checking for TS compilation errors (there shouldn't be any).
- ts-migrate の方で対応できなかった Typescript エラーに対して
@ts-expect-error
コメントを付与して開発者に意図的にエラーとして気づかせるようにします
- ts-migrate の方で対応できなかった Typescript エラーに対して
Typescript Deep Dive の「JavaScript からの移行ガイド」の移行プロセスで言うところの、以下の太字のところをts-migrate
は自動で対応してくれます。
- tsconfig.json を追加する
- ソースコードの拡張子を .js から.ts に変更する。any 型を使ってエラーを抑制する
- 新しいコードは TypeScript で記述し、できるだけ any を使わないようにする
- 古いコードに戻り、型アノテーションを追加し、バグを修正する
- サードパーティ製 JavaScript コードの型定義を使用する
このツールを利用することで、移行作業にかかる時間が大幅に節約できます。(単純作業で目が死ぬこともない)
手順
-
ts-migrate を実行する前の下準備をする
- 移行用のブランチを作る・・・
git checkout -b ts-migrate
- 最新の状態にする・・・
git pull origin master
-
node_modules
を最新の状態にしておく・・・npm install
- 移行用のブランチを作る・・・
-
ts-migrate をインストールする
npm install --save-dev ts-migrate
- プロジェクト内に移行ツール入れたくなければ global でも良かった気がする
-
ts-migrate を実行する
npx ts-migrate-full [移行対象のディレクトリ(srcとか)]
-
途中で確認コマンドでてくるので Enter で進めつつ ts-migrate が終わるのを待つ
- 完了メッセージがでれば OK
--- All done!
- 完了メッセージがでれば OK
ts-migrate で出力された指摘を手作業で修正し、指摘コメントを削除していく
ts-migrate で自動で直せなかった箇所については、ts-migrate がコメントをつけてくれています。
// @ts-expect-error ts-migrate(XXXX) FIXME:
指摘されたコメント部分を対応すると、コメント自体が赤くなるので、コメントを消し、その指摘箇所については対処完了となります。
自分が対応した指摘については後半でまとめています。
依存ライブラリに必要な@types
を install していく
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module
このエラー系は@types
ライブラリが足りないので追加でインストールしていきます
例えば以下のような指摘です
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'reac... Remove this comment to see the full error message
import React from "react";
npm install -D @types/react
で解決します。
型が無くてエラーになっているところに対して any で型を付与していく
-
tsconfig.json
に any を許容する設定を入れる-
noImplicitAny
がコメントアウトされてるので外す。一応 TODO も入れておいてます
{ "compilerOptions": { // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ } }
{ "compilerOptions": { // TODO: 一時的にanyを許容するためfalseにしているが、いずれtrueにする "noImplicitAny": false /* Enable error reporting for expressions and declarations with an implied `any` type.. */ } }
-
-
型推論できないコードに対して any をつけていく
-
通常パターン 1
let target;
↓
let target: any;
-
通常パターン 2
export default async function getHoge(from, to) {...}
↓
export default async function getHoge(from: any, to: any) {...}
-
window 関数のパターン
window.debug();
↓
(window as any).debug();
-
-
(追加で)引数足りなくて怒られる関数にはとりあえず
undefined
を入れる- 例
↓
// getAPIClientAsyncは引数を与えることもできる const apigClient = await getAPIClientAsync();
// TODO: 後で関数のIFに'?'を付与して optional Chaining にし、undefinedを削除する const apigClient = await getAPIClientAsync(undefined);
- 例
API との IF の型を定義して適用する
-
例
export default async function getBackendInfo({startDate,endDate}: any){ const apiResponseData = await ... ... ... return apiResponseData; }
↓
interface GetBackendInfoReq { startDate:string; endDate?:string; } interface GetBackendInfoRes { data: { name: string; value: number; } } export default async function getBackendInfo({startDate,endDate}: GetBackendInfoReq): Promise<GetBackendInfoRes>{ const apiResponseData = await ... ... ... return apiResponseData; }
any 型を付与したところを順次型付けしていく
-
例 1
let target: any;
↓
interface TargetObject { name: string; value: number; } let target: TargetObject;
-
例 2
export default async function getHoge(from: any, to: any) {...}
↓
export default async function getHoge(from: string, to: string) {...}
対処したエラー一覧
ts-migrate(7016)
-
内容
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'react... Remove this comment to see the full error message import React from "react";
-
原因
Typescript 用モジュールがなくて読み込めていない -
対処
npm install -D @types/react
ts-migrate(2339)
-
内容
// @ts-expect-error ts-migrate(2339) FIXME: Property 'trace' does not exist on type 'Window & ... Remove this comment to see the full error message window.trace("canceled");
// @ts-expect-error ts-migrate(2339) FIXME: Property 'clients' does not exist on type 'Window ... Remove this comment to see the full error message self.clients.matchAll();
// @ts-expect-error ts-migrate(2339) FIXME: Property 'gtag' does not exist on type 'Global'. global.gtag = () => {};
-
原因
window
領域で認識されていないプロパティを使用している(カスタムプロパティの使用) -
対処
@types
ディレクトリをルートに作り、window.d.ts
を作成する
-
window.d.ts
にカスタムプロパティを設定する(とりあえずany
でも OK)interface Window { loginUser: { [key: string]: any }; trace: any; clients: { [key: string]: any }; gtag: any; } let window: Window;
-
ts-migrate(2571)
-
内容
this.GetHogeList(hoge).then((hoges) => { // @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'. if (hoges.length > 0) { ... } }).catch((e) => { ... }
-
原因
型推論ができない -
対処
型チェック、または any を指定this.GetHogeList(hoge).then((hoges: any) => { if (hoges.length > 0) { ... } }).catch((e) => { ... }
ts-migrate(2362)
-
内容
alertAfter = get(data[ALERT_NAME[index]], 'after') || 0; alertBefore = get(data[ALERT_NAME[index]], 'before') || 0; // @ts-expect-error ts-migrate(2362) FIXME: The left-hand side of an arithmetic operation must... Remove this comment to see the full error message if (alertAfter - alertBefore < 0) { ... }
-
原因
string
型で計算をしようとしている -
対処
-
The left-hand side of an arithmetic operation must be of type 'any', 'number' or an enum type
-
parseInt
などでnumber
型に変換するalertAfter = parseInt(get(data[ALERT_NAME[index]], 'after')) || 0; alertBefore = parseInt(get(data[ALERT_NAME[index]], 'before')) || 0; if (alertAfter - alertBefore < 0) { ... }
-
ts-migrate(2769)
-
内容
-
case1
const imgArray = Object.assign([], typeList); // @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. imgArray.push(customType);
-
case2
video.addEventListener( "playing", () => { this.setState({ videoState: PLAYING }); // @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. }, this );
-
-
原因
- case1
typeList
オブジェクトが暗黙的に型となり、push
しようとしているcustomType
オブジェクトの変数がtypeList
と比べて不足しているものがあった - case2
addEventListener
の引数として当てはまらない
- case1
-
対処
-
case1
any[]
を指定してあげるconst imgArray: any[] = Object.assign([], typeList); imgArray.push(customType);
-
case2
this
を削除video.addEventListener("playing", () => { this.setState({ videoState: PLAYING }); });
-
ts-migrate(2345)
-
内容
window.sessionStorage.setItem( "exp", // @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'number' is not assignable to par... Remove this comment to see the full error message moment(credentials.expireTime).unix() );
-
原因
引数の型が合っていない -
対処
-
型が合うように変換する
window.sessionStorage.setItem( "exp", String(moment(credentials.expireTime).unix()) );
-
styled-component の props で typescript に引っかかる
-
内容
export const Div_Container = styled.div` background: ${(props: any) => (props.color ? props.color : "#ffffff")}; `;
-
原因
styled-component 内での変数が typescript で認識されていないので怒られる -
対処
-
TypeScript で styled-components を使う時 props で style の制御ができなくて困った時の解決法
-
interface を定義する
interface ContainerProps { color?: string; } export const Div_Container = styled.div<ContainerProps>` background: ${(props: ContainerProps) => props.color ? props.color : "#ffffff"}; `;
-
service-worker で isolated ts エラー
-
内容
cannot be compiled under ‘--isolatedModules’ because it is considered a global script file. Add an import, export, or an empty ‘export {}’ statement to make it a module.
-
対処
ts-migrate(2339) 'hot' NodeModule
-
内容
// using hot reload // @ts-expect-error ts-migrate(2339) FIXME: Property 'hot' does not exist on type 'NodeModule'... Remove this comment to see the full error message if (module.hot) { // @ts-expect-error ts-migrate(2339) FIXME: Property 'hot' does not exist on type 'NodeModule'... Remove this comment to see the full error message module.hot.accept("./reducers", () => store.replaceReducer(reducers)); }
-
対処
-
npm install -D @types/webpack-env
Property 'style' does not exist on type 'Element'.
-
内容
Element implicitly has an 'any' type because expression of type '"style"' can't be used to index type 'Element'. Property 'style' does not exist on type 'Element'.
-
対処
スプレッド構文でエラー
-
内容
is not an array type or a string type. Use compiler option '--downlevelIteration' to allow iterating of iterators.
-
対処
-
tsconfig.json
のdownlevelIteration
オプションを有効にする{ "compilerOptions": { // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ } }
↓
{ "compilerOptions": { "downlevelIteration": true /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ } }
Typescript 移行後にチーム内で取り決めたコーディング規約
Typescript への移行が終わったあと、今後も継続的に Typescript としての型品質を上げること、チーム内で型への認識を統一することを目標として以下をコーディング規約に追加しました。
- 新規、改修で手を入れるコードについては any 型の型指定は禁止(既存は暫定的に設定しているので。)
- String,Number 型は使用しない。string,number 型を使う(大文字と小文字の型は違うので注意!)
まとめ
通常の開発業務も行いながら細々とやっていたので 2 ヶ月もかかってしまいましたが、2,3 人で集中して作業すればもっと早く終わったと思います。
最初のマイグレーション作業以降は一つ一つFIXME:
を潰していくパワーゲームなので・・・。
ですが、細々とやっても 1 人で終わらせることができるということが分かったのは良かったと思います。(永遠の作業ではない)
この記事の内容だけでは完全にTypescriptへ移行ができたということにはならないと思います。
まずはJavascriptからTypescriptへ、言語として移行ができるということが最初のステップになるかなと思います。
そして実際に導入された上で、日々の機能改善活動の中でTypescriptとしても品質を上げていくようなリファクタリングを行う流れになるかなと。
Typescript へ移行しようとしているエンジニアの参考になればと思います。
参考
JavaScriptからの移行ガイド
JavaScriptからTypeScriptへ徐々に移行していくには?
JSからTSへの移行ツール、ts-migrateを試してみた