投稿日
JavaScriptコードをTypeScriptの世界で再利用するコツ ~移行PJで得たノウハウ~
もくじ
はじめに
デザイン&エンジニアリング部の浅岡と申します。
今回、JavaScriptで実装されているモバイルアプリをTypeScriptで再構築するプロジェクトに参画しました。
そこでは、大量に存在する純粋な業務ロジック部品を、JavaScriptコードのまま何も変更を加えず再利用しつつ、そのほかの部分をTypeScriptで実装するという方法が採用されました。
この記事では、現行のJavaScript部品を、ロジックには何も手を加えてはいけないという制約の中、なるべくTypeScriptから使いやすいように移行するという作業の中で、有効だったテクニックや、その際の注意点をご紹介したいと思います。
JavaScriptロジックをTypeScriptプロジェクトの中で再利用することを検討されている方、既にそういったプロジェクトに参加されている方にご活用いただければ幸いです。
なぜJavaScriptロジックを再利用するのか?
テスト済みで、元々安定して動作している現行JavaScriptコードをそのまま利用することで、1からすべてTypeScriptで実装しなおすよりも実装やテストのコストを抑えつつ、新たなバグの発生リスクを最小限に抑え品質も確保できる見込みがあるためです。
一方で、JavaScriptは型を明示しない動的な言語であるため、どうしても型情報が不足しており、型により厳密なTypeScriptコード上では、例えばJavaScript部品に引数として何を渡せばいいかわからない、あるいは戻り値として何が返ってくるのかわからないといったことが頻発してしまう恐れがあります。
よって、メリットを最大限享受するためには、何らかの準備や工夫をしつつ移行する必要があります。
JavaScriptファイルの作成単位
例えば、現行JavaScriptコードでは1つのjsファイルに大量の関数が実装されているような場合、これを再利用する際、そのまま1つの大きいjsファイルを持ってくるという選択肢もあるのですが、基本的には、1つの関数に対して1つファイルを作成し移行してくるのがおすすめです。
保守性も上がりますし、移行作業者を分ける場合にコンフリクトが起きにくくなります。
「基本的には」と記述したのは、以下のような場合があるからです。
大きなjsファイル内のJavaScript関数には以下3種類が考えられます。
- 他ファイルから呼び出されるような関数。移行した際はTypeScriptファイルから呼び出されることになる関数。
- 同ファイル内の複数の関数から呼び出されるが、他ファイルからは呼び出されないような共通関数。
- 同ファイル内の1つの関数からしか呼び出されないローカル関数。
1に関しては、前述の通り新しく1つファイルを作成し移行、そして後述する型定義ファイルを作成するのがおすすめです。
(1つの関数に対して、1つのjsファイルと1つの型定義ファイルを作成する形がおすすめです。)
2についても、複数の関数から呼び出されるため1と同じやり方を採用することになります。
ただし、外部から(今回であればTypeScriptから)呼び出されない想定であることを、作成した型定義ファイルにコメントとして残しておくと良いです。
もしくは、ディレクトリを分けておくのが良いかもしれません。
一方で3については、呼び出し元の関数と同じファイル内にプライベートな関数として移行する方が良いです。
TypeScriptから呼び出されないため、後述の型定義ファイル作成の利点が少ないことと、ファイルの一覧を見たときに外部から呼び出される部品だけが見えていた方が保守性を高められるからです。
もちろん、2と同じように別ファイルとして移行しコメントを残しておくといった選択肢や、別ファイルとして移行しディレクトリを分けておくといった選択肢も採用できます。
プロジェクト毎に決める必要がありますが、ルールが統一されていれば問題ないでしょう。
型定義ファイル(d.ts)の作成
JavaScriptは型を明示しない動的な言語である一方、TypeScriptは型に対してより厳密である必要があります。
不足している型情報を型定義ファイルで提供することで、TypeScript側の実装をする際に引数や戻り値の型の判別で困ることや、想定外の結果から実行時エラーにつながるようなことが少なくなるだけでなく、保守性の向上にもつながるため大変有効な手段です。
ちなみに、再利用する対象の関数が多数で複雑な場合、型定義ファイルの作成に相当な工数が必要になります。
今回私がTypeScriptプロジェクトに組み込んだJavaScript再利用ロジックは合計で約13,000行あり、これらの部品に対して(後述する@ts-checkの適用をしたうえで)型定義ファイルを作成するのに合計で2か月近くかかりました。
設計書が全く無かったという事情もありましたが、それとは別にそもそも簡単な作業では無いということを見積もり段階から考慮しておく必要があります。
型定義ファイル(d.ts)とは
型定義ファイルは、型の無いJavaScriptコードに対して型を定義するためのファイルです。
これを作成することによって、JavaScriptで書かれた関数や変数に対して型を明示でき、TypeScript側での静的型チェックが可能となります。
例えば、以下のようなJavaScriptの関数があるとします。
// calculateTotalPrice.js
export function calculateTotalPrice(price, quantity) {
return price * quantity;
}
この関数をTypeScriptコードから呼び出そうとすると、引数のpriceとquantityに何の型の値を渡せばいいかわからず困ります。
私たち人間の目で見れば、price * quantityをしているのだから掛け算できる値、つまり数値(number型)なのかなと予想することはできますが、TypeScriptからすると、型情報が無いためどんな値が渡されたとしても型エラーにすることはできません。たとえ文字列(string型)や真偽値(boolean型)を渡されたとしても静的型チェックによる型エラーは発生しないでしょう。
そこで、以下のような型定義ファイルを作成し、TypeScriptに型情報を提供します。
// calculateTotalPrice.d.ts
export function calculateTotalPrice(price: number, quantity: number): number;
これによってpriceとquantityは数値(number型)であるという情報が追加されたため、数値ではない値を渡そうとすると、型エラーが発生してくれるでしょう。
型定義ファイル作成の難しさ
ただし、ここからが少し難しいところです。
上の例では、calculateTotalPrice関数の引数は2つともnumber型であるという型定義ファイルを作成しました。
これは本当に正しいのでしょうか。
試しに、calculateTotalPrice関数を以下のように呼び出したとします。
const result = calculateTotalPrice("2", "6");
console.log(result);
結果は「12」という数値が出力されます。
では以下のように呼び出したらどうでしょうか。
const result = calculateTotalPrice("abc", "6");
console.log(result);
「NaN」が出力されるはずです。
以下のように呼び出したらどうでしょうか。
const result = calculateTotalPrice("2", true);
console.log(result);
こちらは「2」という出力が得られます。 重要なのはどれも実行時にエラーが発生することは無く、部品単体としてみれば安定して動作しているという点です。 つまり、元々のコードではこの部品に文字列や真偽値を渡していた可能性もあるということになります。 では、以下のような型定義にすればいいのでしょうか。
// calculateTotalPrice.d.ts
export function calculateTotalPrice(price: number | string | boolean, quantity: number | string | boolean): number;
しかし、実は元々のコードではどちらもnumber型の値しか渡していないかもしれません。その場合はstringやbooleanの型定義は不要であり、TypeScript側の実装をする時に混乱してしまう元となります。
もしくは、number | string | boolean以外にもあるかもしれません。
上で例示したcalculateTotalPrice関数は単純な関数でしたが、これが数百行、数千行あるような部品で、引数や戻り値も複雑だったらどうでしょうか。
真の正解は、この部品を使う全ての実装を厳密に確認していかないとわかりません。
再利用するJavaScript部品の量が少ない場合や、移行にたくさんの工数がかけられる場合は、注意深く確認し型定義を行えばいいでしょう。
もし各部品に関する設計書のような、他にインプットとできるものがある場合はそれに従うのもいいでしょう。
しかし、それが難しいような場合、思い切って以下のような方針にしてしまうのも1つの手段です。
「事前に予想できる型定義を作成しておくが、それはあくまでもたたき台のようなものであり、部品を使う際に何か問題があればその都度修正する。」
// calculateTotalPrice.d.ts
/**
* 現行の実装では、引数のpriceとquantityにnumber型以外を渡しても動作するが、
* 典型的な利用シーンを考えるとnumber型が自然のためnumber型で型定義を作成する。
* 現行でのすべての利用個所を調査するのは工数面で非現実的なので、
* この定義だと問題があるということが分かった場合には、その都度型定義を見直すものとする。
*/
export function calculateTotalPrice(price: number, quantity: number): number;
複雑な戻り値に対する型定義の実装
上記のようにd.tsファイルの作成を進めていくと、部品の戻り値が複雑なこともあるでしょう。
例えば以下のような関数があるとします。
// returnComplexObject.js
export function returnComplexObject() {
// 色々な処理
if(何らかの分岐){
return {name: 'Taro', age: 30};
}
// 色々な処理
return {name: 'Bob', birthday: '19970108'};
}
この場合の型定義は以下になるでしょう。
// returnComplexObject.d.ts
export function returnComplexObject(): Aobject | Bobject;
export type Aobject = {name: string; age: number};
export type Bobject = {name: string; birthday: string};
この関数はAobjectもしくはBobjectのどちらかを返します。
もしもageやbirthdayのような、片方にしかないプロパティがあり、そのプロパティにアクセスしたかった場合、TypeScriptでは戻り値がどちらのobjectなのかを判別せずにアクセスすると、型エラーになってしまいます。
const result = returnComplexObject();
console.log(result.age); // プロパティ 'age' は型 'Bobject' に存在しません。ts(2339)
こういった関数は、この部品を使うすべての個所で戻り値の判別が必要になります。
しかし、全ての呼び出し元でそれぞれ個別に判定を実装するのは無駄がありますし、保守性の低下にもつながります。
このような場合には、型ガード関数(や後述するアサーション関数)をあらかじめ作成しておくというテクニックが有効です。
型ガード関数の例は以下のようなものになります。
// returnComplexObject-typeGuard.ts
import {Aobject, Bobject} from './returnComplexObject';
export function returnComplexObjectReturnsAobject(result: Aobject | Bobject): result is Aobject {
// AobjectとBobjectのプロパティの差異に着目し、そのプロパティの有無を判定することで型の絞り込みができます。
return 'age' in result;
}
const result = returnComplexObject();
if(returnComplexObjectReturnsAobject(result)){
console.log(result.age); // 型エラー発生せず
}
型ガード関数の引数:Aobject | Bobjectの部分はReturnTypeを使用してもいいでしょう。
// returnComplexObject-typeGuard.ts
import {Aobject, returnComplexObject} from './returnComplexObject';
export function returnComplexObjectReturnsAobject(result: ReturnType<typeof returnComplexObject>): result is Aobject {
return 'age' in result;
}
上記の例では戻り値にageというプロパティがあるかどうかで判定していますが、プロパティの名前が変更された場合や、プロパティ自体が削除された場合に検知できるように以下のようにしておくテクニックも有効です。
// returnComplexObject-typeGuard.ts
import {Aobject, returnComplexObject} from './returnComplexObject';
export function returnComplexObjectReturnsAobject(result: ReturnType<typeof returnComplexObject>): result is Aobject {
if ('age' in result) {
return returnTrue(result);
}
return false;
}
/**
* プロパティ名が変わった場合や削除された場合等、型ガード関数で正しく型が絞り込まれていない時に型エラーを発生させるための関数。
* 正しく型が絞り込めていない時は、returnTrue関数の引数に「型:Aobject」では許容できない型のまま渡されるため、型エラーとなる。
*/
function returnTrue(_returnValue: Aobject) {
return true;
}
※ プロパティが削除やリネームされた結果、もう片方のオブジェクトに包含されるような型となった場合は検知できないことがあります。過信は禁物です。
また、明らかに片方の戻り値が失敗時用のオブジェクトだった場合など、片方のオブジェクトを参照する処理が必要無い場合や、型定義上は存在するけれど、実際のロジックでは片方のオブジェクトが返ることにはならない(考慮しなくてもいい)というような場合には、アサーション関数を作成するのが有効です。
// returnComplexObject2.d.ts
export function returnComplexObject2(): SuccessObject | FailureObject;
export type SuccessObject = {name: string; age: number};
export type FailureObject = {name: undefined};
// returnComplexObject2-assertion.ts
import {SuccessObject, returnComplexObject2} from './returnComplexObject2';
export function returnComplexObject2ReturnsSuccessObject(
result: ReturnType<typeof returnComplexObject2>,
): asserts result is SuccessObject {
if ('age' in result) {
return;
}
throw new Error('result is FailureObject!!');
}
const result = returnComplexObject2();
returnComplexObject2ReturnsSuccessObject(result);
console.log(result.age); // 型エラー発生せず
@ts-checkの適用による型チェックの導入
前提条件
ここからは、再利用するJavaScriptコードに多少なりとも手を加える手法となります。(もちろんロジックには手を加えません。)
加えた変更をコミットせずにローカル環境でのチェックのみする場合は問題ありませんが、少しも既存コードを変更したくないというプロジェクトでは採用しにくいかもしれません。
@ts-checkの導入
上記の型定義ファイル作成時には、JavaScriptコードを読んで型を慎重に追っていく必要があります。
レビューの際には型定義ファイルに定義されている型が正しいかを丁寧にチェックする必要もあります。
ただ、これを人の目で実施するのには限界があります。
実装者にとっても、レビュアーにとっても大変な作業であり、漏れが発生してしまうリスクもあります。
そこで@ts-checkで、JavaScriptコードにTypeScriptと同じ型チェックを導入する方法がかなり有効です。
再利用するJavaScriptファイルの先頭に「// @ts-check」というコメントを追加するだけで導入できます。
※ もし@ts-checkを導入したいJavaScriptファイルがたくさんある場合は、tsconfig.jsonもしくはjsconfig.jsonに設定を追加することで、jsファイル全てに@ts-checkをかけるということもできます。(この時、逆に@ts-checkをかけたくないファイルについてはファイルの先頭に「// @ts-nocheck」というコメントを追加します。)
例えば、以下のようなJavaScriptの関数があったとします。
// returnNumber.js
export function returnNumber(){
var n = 0;
// 色々な処理
if(何らかの分岐) {
n = 'no number';
}
// 色々な処理
return n;
}
もしも「n = ‘no number’」という部分を見逃してしまった場合、関数名やそのほかの実装から以下のような型定義を作成してしまうかもしれません。
// returnNumber.d.ts
export function returnNumber(): number;
この関数をTypeScriptから呼び出した場合、戻り値はnumber型だと判断されます。
しかし実際には、string型の値が返ってくることがあり、その場合は実行時エラーになってしまうかもしれません。
const n = returnNumber();
console.log(n.toFixed(2)); // 変数nにstring型が入っていた場合、実行時エラーが発生する
では、returnNumber.jsファイルの先頭に// @ts-checkコメントを追加してみます。
// returnNumber.js
// @ts-check
export function returnNumber(){
var n = 0;
// 色々な処理
if(何らかの分岐) {
n = 'no number';
}
// 色々な処理
return n;
}
「n = ‘no number’;」の行で型エラーが発生してくれます。
この型エラーによって、変数nはnumber型だけではなくstring型も代入される可能性があることに気が付けるはずです。
このように、型の判別をある程度@ts-checkに委ねることでJavaScript部品の理解と型定義が少しやりやすくなります。
※ 「// @ts-check」はあくまでもTypeScriptと同じ型チェックを適用するための目印なので、これによって型エラーが発生していても、JavaScriptコードとして問題ないコードであれば無視して動かすことはできます。
JSDocの併用
では、変数nに対してさらに複雑な処理が行われていた場合はどうでしょうか。
nを使って他の計算処理をしていたり、他のJavaScript部品に渡していたり、もしくは他のJavaScript部品の戻り値をnに代入しているかもしれません。
そのような、@ts-checkによる型判別だけでは型を追っていくのが困難な場合、JSDoc(参考:JSDocリファレンス)を使って型判別の補強をしてあげるのが有効です。
実は、@ts-checkはJSDocと併用することで真価を発揮すると言っても過言ではありません。
JSDocとは、JavaScriptコードに対してコメントを使って型情報や関数の説明を付与するためのものです。
上の例では、型エラーによって変数nはnumber型だけでなくstring型も格納されることがわかりました。
そこで、JSDocを使って変数nに型情報を付与してみます。
// returnNumber.js
// @ts-check
export function returnNumber(){
/**
* @type {number | string}
*/
var n = 0;
// 色々な処理
if(何らかの分岐) {
n = 'no number';
}
// 色々な処理
return n;
}
これで、ロジックには手を加えずに変数nに対して型情報を付与できました。
「n = ‘no number’;」で発生していたエラーも解消されているはずです。
仮に、この変数nに対してさらに他の処理を実施していたとしても、型が追いやすくなるはずです。
このようにして適宜型情報を付与していくことで、JavaScriptコードの型が追いやすくなります。
これは型定義ファイルを作成する実装者だけでなく、それをレビューするレビュアーや、不具合調査や保守等で後々このコードを読む人にとっても利益になります。
また、JSDocは型定義ファイルの作成と組み合わせることもできます。
例えば、引数を取るdivideという関数があった場合、以下のようになります。
// divide.d.ts
export function divide(a: number, b: number): number | string;
// divide.js
// @ts-check
/**
* @type {import('./divide').divide}
*/
export function divide(a, b){ // divide.d.tsから、aとbはnumber型だと解釈してくれる
/**
* @type {number | string}
*/
var n = a / b;
if(b === 0) {
n = 'incalculable';
}
// 戻り値としている変数nが、divide.d.tsに記載のnumber | string型と一致しなかった場合、型エラーが発生し検知することができる。
return n;
}
私が複雑なJavaScript部品の型定義ファイルを作成する際は、最初に「@type {import(‘./divide’).divide}」を記述し、適宜@typeを使って型情報を付与して丁寧に型を追いながら、少しずつ型定義ファイルを完成させていくというやり方が一番やりやすかったです。
※ ローカル関数の場合など、型定義ファイルを作成しない時は以下のようにすることで個別に引数と戻り値の型定義をすることができます。
// divide.js
// @ts-check
/**
* @param {number} a
* @param {number} b
* @return {number | string}
*/
function divide(a, b){
/**
* @type {number | string}
*/
var n = a / b;
if(b === 0) {
n = 'incalculable';
}
return n;
}
どうしても型エラーが発生してしまうような場合
@ts-checkはTypeScriptと同じ型チェックをJavaScriptに導入するものなので、JavaScriptでは許されている実装でも型エラーが発生してしまう場合があります。
例えば以下のようなJavaScriptの関数があるとします。
// calculateTotalPrice2.js
export function calculateTotalPrice2(price, quantity) {
var result = {
subTotal: price * quantity,
};
result.tax = (result.subTotal * 8) / 100;
result.total = result.subTotal + result.tax;
return result;
}
型定義ファイルは以下です。
// calculateTotalPrice2.d.ts
export function calculateTotalPrice2(
price: number,
quantity: number,
): {
subTotal: number;
tax: number;
total: number;
};
このcalculateTotalPrice2関数に@ts-checkと、引数・戻り値の型定義を付与してみます。
// calculateTotalPrice2.js
// @ts-check
/**
* @type {import('./calculateTotalPrice2').calculateTotalPrice2}
*/
export function calculateTotalPrice2(price, quantity) {
var result = {
subTotal: price * quantity,
};
result.tax = (result.subTotal * 8) / 100;
result.total = result.subTotal + result.tax;
return result;
}
すると、result.taxに値を代入している行、result.totalに代入している行、そしてresult変数をreturnしている行で型エラーが発生してしまいます。
JavaScriptでは、既に存在するオブジェクトに対してプロパティを追加するような処理は問題ないのですが、TypeScriptの型チェックではエラーとなってしまいます。
result変数は宣言時の代入から以下の型だと解釈されます。
result: {
subTotal: number;
}
そのため、taxやtotalというプロパティは無いという型エラーが発生してしまうのです。
TypeScriptの型チェックではエラーとなってしまいますが、JavaScriptコードの動作としては問題ないはずです。
先述した通り、発生した型エラーをそのままにして進めることはできますが、以下のように@ts-expect-errorを使うことで型エラーを無視しておくこともできます。
こうすることで、何故そこで型エラーが発生するのかわかりやすくなりますし、予想外の型エラーに気が付きやすくもなります。
// calculateTotalPrice2.js
// @ts-check
/**
* @type {import('./calculateTotalPrice2').calculateTotalPrice2}
*/
export function calculateTotalPrice2(price, quantity) {
var result = {
subTotal: price * quantity,
};
// @ts-expect-error JavaScriptでは既存のオブジェクトに後からプロパティを追加するコードでも問題なく動作する。
result.tax = (result.subTotal * 8) / 100;
// @ts-expect-error JavaScriptでは既存のオブジェクトに後からプロパティを追加するコードでも問題なく動作する。
result.total = result.subTotal + result.tax;
// @ts-expect-error taxとtotalプロパティはresult変数に後から追加される
return result;
}
エラーの無視には他にも@ts-ignoreを使う手がありますが、@ts-expect-errorであれば、該当行で型エラーが発生しなくなると、逆に@ts-checkによるエラーが発生し、検出できるのでこちらの方がいいでしょう。
// @ts-check
/**
* @type {import('./calculateTotalPrice2').calculateTotalPrice2}
*/
export function calculateTotalPrice2(price, quantity) {
var result = {
subTotal: price * quantity,
tax: 0, // taxが代入時の型定義に存在する場合
};
// taxは既存のオブジェクトに存在するプロパティとなったので、↓の行で@ts-checkによるエラーが発生する。
// @ts-expect-error JavaScriptでは既存のオブジェクトに後からプロパティを追加するコードでも問題なく動作する。
result.tax = (result.subTotal * 8) / 100;
// @ts-expect-error JavaScriptでは既存のオブジェクトに後からプロパティを追加するコードでも問題なく動作する。
result.total = result.subTotal + result.tax;
// @ts-expect-error taxとtotalプロパティはresult変数に後から追加される
return result;
}
エラー内容は以下です。
Unused '@ts-expect-error' directive.ts(2578)
ただし、この@ts-expect-errorの利用には細心の注意を払う必要があります。
JavaScriptコードの型を追ううえで、本当に無視して問題ない型エラーなのかという確認を疎かにすると、型定義の不備や漏れ、新たなバグの発生につながるリスクがあるためです。
他にも、@ts-expect-errorは発生するエラーの内容まではチェックされません。期待したエラー以外の問題が発生してもそのまま無視されてしまうため、検知することができません。
今回私が参画したプロジェクトのような、現行JavaScriptコードの移行・再構築プロジェクトの場合は、仮に@ts-checkによって型エラーが発生したり、問題がありそうなコードを発見したりしたとしても、現行の本番運用で問題になっていないのであればリスク低として再構築段階では敢えて許容し、後々対応を考えるという方針もありです。
おわりに
ここまで、TypeScriptプロジェクト内にJavaScriptコードを共存させる際に有効だったテクニック、以下3点をご紹介しました。
- JavaScriptファイルの作成単位
- 型定義ファイル(d.ts)の作成
- @ts-checkの適用による型チェックの導入
JavaScriptは良い意味でも悪い意味でも自由度が高い言語のため、これをTypeScriptプロジェクトに組み込む際には様々な問題が発生する可能性があります。
この記事でご紹介した工夫を施したJavaScript部品であれば、部品を使う際に困ることも減り、新たなバグの発生のリスクも抑えられるでしょう。
また、JavaScriptコードが型について整理されることになるため、保守性の向上も見込めるでしょう。
ただし、型定義ファイル(d.ts)の作成の章でも記述しましたが、JavaScriptコードの型を追い、型定義ファイルを作成していくのは時間がかかる作業です。
仮に@ts-checkを使用し型を追ったとしても、(時間は短縮され、漏れは減るとは思いますが)大変な作業というのは変わりません。
そのことは念頭に置いておく必要があります。
そのうえで、工夫を施しJavaScript部品を再利用するのか、TypeScriptで1からすべて実装しなおすのか、もしくは工夫をせずにJavaScript部品をそのまま流用し使用するのかというのをプロジェクトごとの特性に従って判断する必要があります。
そのようなときに、少しでも本記事が参考になりましたら幸いです。
最後まで読んでいただきありがとうございました。
