動いているJavaScriptをTypeScriptへ移したい。でも、すべての.jsを.tsへ変えると、今の仕事を止めて型エラーを直すことになりそう。そんなときは、拡張子より先に、型検査の範囲を少しずつ増やします。
allowJsでJSとTSを同じプロジェクトに取り込み、checkJsとJSDocでJSのまま検査を始め、依存の少ない関数から.tsへ変えるのが段階的な進め方です。strictを有効にする段階では、DOMが見つからない場合など、これまで暗黙に置いていた前提も確認します。
ここでは、数量から合計を計算する小さなViteアプリを3段階で変えます。TypeScript 7.0.2、Vite 7.3.6、Node.js 24.13.0で確認した例です。3段階とも、入力2なら2400円、0・小数・空欄ならエラー文を出す動作を保ちます。
型検査を入れる前に、変えない動作とコマンドを決める
最初に、現状の起動・ビルド・主要操作を確認します。この例の依存は、main.jsから計算関数total.jsを呼ぶだけです。DOMを扱わないtotal.jsが依存の葉なので、先に型を付ける対象にします。
| 段階 | ファイル | checkJs / strict | 確かめること |
|---|---|---|---|
| 1:検査の入口を置く | main.js・total.js | false / false | 既存のJSを取り込める。JSの型検査完了とは数えない |
| 2:JSのまま検査する | main.js・JSDoc付きtotal.js | true / false | 引数の型不一致を検出できる |
| 3:葉をTSへ移す | main.js・total.ts | true / true | 混在したまま検査し、DOMのnullも扱う |
すでにstrictで運用しているプロジェクトの設定を下げる手順ではありません。これは型検査がないJSから始める例です。ESLint、既存テスト、ビルドは残し、型検査のコマンドを追加します。
空の検証用フォルダーで試す場合のpackage.jsonは次のとおりです。実際のプロジェクトでは、既存の依存やscriptsを消さず、必要な差分だけ追加してください。
{
"name": "gradual-typescript-example",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1 --port 5221",
"typecheck": "tsc --noEmit --pretty false",
"build": "npm run typecheck && vite build"
},
"devDependencies": {
"typescript": "7.0.2",
"vite": "7.3.6",
"@playwright/test": "1.61.1"
}
}
作業開始時はnpm install、その後はnpm run devで起動します。各段階でnpm run typecheckとnpm run buildを確認します。buildスクリプトは型検査が失敗するとViteを実行しない構成です。
Viteの公式説明にあるとおり、TypeScriptの変換と型検査は別です。この例でも、型不一致を入れたソースはVite単体のビルドが通りました。lintやテストの代わりに型検査を置くのではなく、それぞれの検査を残します。
段階1:allowJsとnoEmitで、元のJSを保ったまま取り込む
tsconfig.jsonを次の設定で作ります。allowJsはJSを含められるようにする設定、noEmitはTypeScriptから変換済みファイルを出力しない設定です。この構成では、配信用の変換はViteが担当します。
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"ES2022",
"DOM"
],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowJs": true,
"checkJs": false,
"strict": false,
"noEmit": true,
"isolatedModules": true,
"types": []
},
"include": [
"src/**/*"
]
}
moduleResolution: Bundlerは、ここで使うViteのブラウザ向けESM構成に合わせています。Node.jsで直接実行するサーバーやCommonJSへ、同じ設定を無条件に当てはめるものではありません。types: []も、この小例がNodeやテストランナーのグローバル型を必要としないための指定です。
以下のindex.htmlとsrc/main.js、src/total.jsを用意すると、移行前の状態を試せます。
index.html
<!doctype html>
<html lang="ja">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>数量から合計を計算</title></head>
<body>
<h1>単価1,200円の合計を計算</h1>
<form novalidate><label>数量 <input name="quantity" type="number" min="1" step="1" value="2"></label><button>計算</button></form>
<p><output aria-live="polite">数量を入力してください。</output></p>
<script type="module" src="/src/main.js"></script>
</body>
</html>
src/main.js
import { lineTotal } from './total.js';
const form = document.querySelector('form');
const input = document.querySelector('input');
const output = document.querySelector('output');
form.addEventListener('submit', event => {
event.preventDefault();
const quantity = input.valueAsNumber;
if (!Number.isInteger(quantity) || quantity < 1) {
output.textContent = '数量は1以上の整数で入力してください。';
return;
}
output.textContent = `${lineTotal(1200, quantity)}円`;
});
src/total.js
export function lineTotal(unitPrice, quantity) {
return unitPrice * quantity;
}
この時点でnpm run typecheckが成功しても、「すべてのJSに型の問題がない」とは言えません。checkJsをfalseにしているためです。allowJsは混在を可能にする設定で、checkJsのようにJS内の型エラーを報告する指定とは役割が違います。
includeは最初に集める対象の指定です。範囲外のファイルでもimportから取り込まれることがあります。excludeの公式説明も、除外指定だけで依存先の取り込みを完全に防ぐものではないとしています。調べるときはtsc –listFilesOnlyなどで実際の対象を確認します。
段階2:checkJsを有効にし、関数の入口へJSDocを付ける
次はtsconfig.jsonを以下へ変更します。変わるのはcheckJsです。src/total.jsには引数と戻り値の型をJSDocで書きます。ファイルの拡張子はまだ.jsのままです。
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"ES2022",
"DOM"
],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowJs": true,
"checkJs": true,
"strict": false,
"noEmit": true,
"isolatedModules": true,
"types": []
},
"include": [
"src/**/*"
]
}
/**
* @param {number} unitPrice
* @param {number} quantity
* @returns {number}
*/
export function lineTotal(unitPrice, quantity) {
return unitPrice * quantity;
}
実装に合う型を付けることが大切です。この関数は数値の単価と数値の数量を受け取る設計なので、両方をnumberにしています。「呼び出し側を直したくないから全部any」とすると、欲しかった検査を通り抜けてしまいます。
たとえば、数値の2ではなく文字列の’2’を渡すと、次の呼び出しは型検査で失敗します。
lineTotal(1200, '2'); // 引数の型が違う失敗例
検証ではTS2345が出ました。JavaScriptの掛け算では文字列が数値へ変換されて2400になる場合がありますが、「たまたま動いた」と「関数へ渡す型が合っている」は別です。通常のmain.jsではinput.valueAsNumberで数値を受け取り、整数かどうかも実行時に確かめています。
大きなプロジェクトで一度にcheckJsを有効にできない場合は、checkJs: falseを保ち、先に検査するJSの先頭へ// @ts-checkを付ける方法もあります。その場合は、対象にしたファイルを記録してください。「JSが何本あるか」と「何本を検査しているか」を同じ数字として扱わないようにします。
段階3:total.jsをtotal.tsへ変え、strictで前提を見直す
計算関数のJSDocが整ったら、src/total.jsをsrc/total.tsへ名前変更し、型注釈をTypeScriptの構文へ移します。同名のtotal.jsを残すと解決先が分かりにくくなるため、複製を並べるのではなく名前変更します。
export function lineTotal(unitPrice: number, quantity: number): number {
return unitPrice * quantity;
}
続けて、tsconfig.jsonを次のようにします。JSがまだ残っているためallowJsとcheckJsはtrueのままです。strictは複数の厳格な検査をまとめて有効にする指定であり、すべての追加検査オプションを網羅する「全部入り」ではありません。
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"ES2022",
"DOM"
],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowJs": true,
"checkJs": true,
"strict": true,
"noEmit": true,
"isolatedModules": true,
"types": []
},
"include": [
"src/**/*"
]
}
この段階では、querySelectorがnullを返す可能性も確認が必要になります。main.jsはJSのまま、要素が見つからない場合の処理を加えます。
import { lineTotal } from './total.js';
const form = document.querySelector('form');
const input = document.querySelector('input');
const output = document.querySelector('output');
if (!form || !input || !output) {
throw new Error('計算フォームの要素が見つかりません。');
}
form.addEventListener('submit', event => {
event.preventDefault();
const quantity = input.valueAsNumber;
if (!Number.isInteger(quantity) || quantity < 1) {
output.textContent = '数量は1以上の整数で入力してください。';
return;
}
output.textContent = `${lineTotal(1200, quantity)}円`;
});
末尾の!や型アサーションで「あることにする」のではなく、実際に存在するか確認しています。この例でフォーム自体がないのはHTMLとの不整合なので、初期化時にエラーにしています。フォームが任意のページへ共通読み込みする場合は、未配置を正常として初期化をやめるなど、用途に合わせて変えます。
importは./total.jsのままですが、このTypeScriptとViteの構成では、名前変更後のtotal.tsへ解決されることを型検査・ビルド・ブラウザで確認しました。ブラウザが.tsをそのまま読み込んでいるという意味ではありません。拡張子の扱いは実行方式と解決設定によって変わるため、Node.js向けの別構成へコピーするときは確認し直します。
strictを有効にしても、入力値が1以上の整数になるわけではありません。numberにはNaNや小数も含まれるため、Number.isIntegerと範囲チェックは残します。外部JSONの型も、型注釈を書くだけで実データが検証されるわけではありません。
次の1ファイルは、エラー件数だけでなく依存と検査範囲で選ぶ
今回の3段階は、通常ソースの型検査・ビルドがすべて成功し、幅390/1440のChromeで同じ入力結果になりました。それとは別に、型不一致を入れた小さな検証用ソースで、検査が働く条件を確かめました。
| 検証条件 | 結果 |
|---|---|
| JSDoc付き関数へ文字列を渡すJS、checkJs: false | 型エラーの報告なし |
| 同じ型不一致、checkJs: true | TS2345を検出 |
| TS化した関数へ文字列を渡すJS、checkJs: true | TS2345を検出 |
| strict下でquerySelectorの結果を未確認で使う | nullの可能性を示すTS18047を検出 |
| TS2345が出るソースをVite単体でビルド | 変換は成功。型検査を別途実行する必要がある |
移行の記録には、JS/TSの本数、checkJsの範囲、strictの状態、エラー件数、主要操作の結果を並べます。検査対象を減らして0件になった結果を、修正が進んだ結果と混ぜないためです。
| 記録するもの | この例の段階3 |
|---|---|
| JS / TSのファイル数 | 1 / 1 |
| 型検査の設定 | allowJs・checkJs・strictがtrue |
| 通常ソースの型エラー | 0件 |
| 確認した動作 | 通常値、0、小数、空欄、正しい値への戻し |
| 次の候補 | DOM入口のmain.js。計算関数とは別の変更として扱う |
次も、入出力が小さく、依存が少なく、動作を確認できるファイルから進めます。共通関数でも利用箇所が多いなら、呼び出し側への影響を先に調べます。設定や拡張子を一度に変えすぎず、戻せる単位で差分を残してください。
どうしても暫定的なanyや@ts-expect-errorが必要なら、理由、直す対象、削除条件を記録します。@ts-expect-errorの公式説明では、次の行にエラーがなくなると、不要な指定として報告されます。ただし、説明コメントの内容に合ったエラーだけを選んで抑制する機能ではないため、広い範囲の修正を隠す用途には向きません。
この一記事で全ファイルをTSへ変える必要はありません。まずJSのまま検査し、計算関数1本を移し、呼び出し側の動作が同じか確認する。そこまでを1回の変更として終えると、次のファイルへ進む判断材料が残ります。ビルドや互換性変換の役割を整理したい場合は、JavaScript開発の全体像も参照してください。