TypeScriptを知らないままゲームを移植した — 後から効いた言語知識7つ

TypeScript

ブラウザローグライク(デモはこちら)は、最初は素のJavaScriptで書いた。その後CI/CDを組む段になって、型チェックをゲートにしたくなり、TypeScriptに移植した。

白状すると、この時点でTypeScriptをちゃんと書いたことがなかった。「JSに型を書き足すやつ」程度の認識で移植を始め、詰まるたびに理解を継ぎ足した。結果として動いているが、あとから「先に知っていれば迷わなかった」と思う要点がいくつもあった。同じ入り方をする人のために、実際のコードで踏んだ実例だけを7つに絞って書く。

先に基礎 — TypeScriptとは何か

7つの実例に入る前に、最低限の基礎を3行で押さえておく。

  • TypeScriptはJavaScriptのスーパーセットである。有効なJSはそのまま有効なTSで、そこに型の記法が足されている。拡張子は .ts
  • ブラウザもNode.jsも .ts を直接実行できない(Nodeは実験的対応があるが本番はまだ)。コンパイルしてJSに変換してから実行する
  • 設定は tsconfig.json に書く。迷ったら "strict": true 一択である。緩い設定で始めると型の恩恵が半減し、あとから厳しくするのは移植をもう一度やるのに近い

このプロジェクトの tsconfig.json はこれで全部だ。

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "noEmit": true,
    "strict": true,
    "noUnusedLocals": true,
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "types": ["node"]
  },
  "include": ["src", "tests", "scripts"]
}

noEmit: true が「tscは検査だけ、変換は別の道具」という宣言で、次の要点1に直結する。

1. 型は実行されない — いちばん大事な前提

TypeScriptの型は、実行時には存在しない。ビルド時に全部剥がされて、ただのJavaScriptになる。

このプロジェクトのビルドはesbuildだが、esbuildは型を検査しない。剥がすだけだ。検査は tsc --noEmit が別に担う。つまり道具の役割分担はこうなる。

tsc --noEmit   型の検査だけ(出力しない)
esbuild        型を剥がしてJSに束ねる(検査しない)
vitest         テスト実行

これを知らないと「esbuildが通ったのに型エラーって何?」と混乱する(した)。型チェックとビルドが独立しているからこそ、CIでは型チェックだけを高速に回せる。

2. 型注釈は思ったより書かない — 推論を信じる

移植前は「全部の変数に型を書くのか、面倒だな」と思っていた。実際は逆で、TypeScriptは代入から型を推論するので、注釈を書く場所は限られる。

const total = parts.reduce((a, x) => a + x.value, 0); // number と推論される

書くのは主に「境界」だ。関数の引数と戻り値、そしてデータ構造の定義。中身のローカル変数はほぼ書かない。「注釈だらけのJS」を想像していたが、実物は思ったより素のJSに近い。

3. データの形はinterfaceで先に決める

移植で一番効いたのがこれ。ゲームの状態を全部 types.ts に集めて、形を宣言した。

export interface MonsterDef {
  key: string;
  name: string;
  atk: number;
  crit?: number;    // 会心率。省略時はデフォルト値を使う
  corrode?: number; // 盾を錆びさせる確率。持つ敵だけ書く
}

? を付けたプロパティは省略できる。18種の敵のうち錆攻撃を持つのは2種だけなので、その2種にだけ corrode を書けばいい。JSなら「undefinedかもしれないプロパティ」を頭で管理するところを、宣言が管理してくれる。

データの形を先に決めると、あとは全ファイルでその形からの逸脱がエラーになる。仕様書とテストを兼ねた1枚が手に入る感覚だ。

4. ユニオン型が実質enum — 状態管理が一気に楽になる

UIの状態遷移(プレイ中・持ち物・ダイアログ…)は、文字列のユニオン型で書いた。

type UiMode = 'play' | 'stairs' | 'inventory' | 'item-action' | 'score' | 'name' | 'ranking';
let uiMode: UiMode = 'play';

これで uiMode = 'invenotry'(typo)がコンパイルエラーになる。JS時代はこのtypoが「なぜかダイアログが閉じない」という実行時の謎バグになっていた。文字列リテラルのユニオンは、enumを別途覚えなくても実質enumとして使える。まずこれで足りる。

5. null安全の道具は4つ — ただし「白状」も1つある

strictモードだと null / undefined の扱いが厳格になる。使い分けはこの4つで足りた。

p.shield.plus ?? 0    // ??  null/undefinedのときだけ右を使う
game.cause ?? '不明'
item?.def             // ?.  nullなら以降を評価せずundefined
it.def.atk!           // !   「絶対nullじゃない」と開発者が断言(自己責任)

??|| の違いは早めに知るべきだった。plus || 0plus0 のときも右辺に落ちる(0はfalsy)。強化値+0の盾で実際に踏みかけた罠で、数値を扱うなら ?? 一択である。

白状すると、1箇所ズルもしている。

let game: Game = null as unknown as Game; // 起動時に必ず start() が設定する

「起動直後の一瞬だけnull、でも以降は絶対ある」という変数を、二段キャストで型システムから隠した。as は「型チェッカーを黙らせる」道具であって安全にする道具ではない。使った場所には、なぜ安全なのかをコメントで書き残すことにしている。

6. 型の互換は「名前」ではなく「形」で決まる

TypeScriptは構造的型付けで、型の一致は名前ではなく中身の形で判定される。これを体感したのが、つい今日のエラーだ。シミュレーション結果に診断用の項目を1つ足したら、こう怒られた。

error TS2353: Object literal may only specify known properties,
and 'shieldPlus' does not exist in type 'RunResult'

結果オブジェクトを作る側に shieldPlus を足したが、RunResult インターフェースの宣言を直し忘れた。JSなら黙って通り、使う側で undefined が出て初めて気づく。TSは書いた瞬間に、宣言と実物の形のズレを指摘してくる。修正はinterfaceに1行足すだけだった。

地味だが、これが型の日常的な価値だと思う。派手なバグを捕まえるのではなく、形のズレをその場で潰し続けてくれる

7. 型が守るのはコンパイル時だけ — 境界では実物を検証する

最後は限界の話。型が消える(要点1)ということは、外から来るデータには型の保証が一切ない

このゲームはランキングをlocalStorageとサーバーAPIに保存するが、JSON.parse の戻りは何でもありだ。型を付けても、それは「こうであってほしい」という願望にすぎない。だから境界では実物を検証する。

const list = JSON.parse(raw);
return Array.isArray(list) ? list : []; // 壊れていたら空扱い

fetch のレスポンスも同じで、data.top が本当に配列かは実行時に確かめる。プログラム内部の整合性は型が守る。外との境界は自分で守る。この線引きを知らないと、「TSなのに実行時に落ちる」と型に裏切られた気分になるが、最初からそういう契約なのだ。

知っておくべきツール — 最初に揃える6つ

言語と同じくらい「どの道具が何を担当するか」で迷ったので、このプロジェクトで実際に使っている構成を役割付きで並べておく。全部 npm install -D で入る。

ツール 役割 このプロジェクトでの使い方
typescript (tsc) 型の検査 npx tsc(noEmitなので検査のみ)。CIの必須ステージ
esbuild 変換と束ね .ts から型を剥がして1本のJSに。体感一瞬(10ms前後)
vitest テスト .ts のテストをそのまま実行できる。Jest互換の書き味
typescript-eslint Lint 型情報を使った静的検査。未使用変数や危険な書き方を拾う
tsx Nodeで直接実行 ビルドせずに npx tsx script.ts。シミュレーション等の開発スクリプト用
@types/* 型定義の後付け 型を持たないJSライブラリに型を与える。今回はゼロ依存なので不使用

補足を3つ。

  • 役割が重複しないことが大事だ。tscは検査だけ、esbuildは変換だけ、と割り切ると、それぞれ最速の道具を選べる。「tscでビルドもする」構成は小規模なら可能だが、遅い
  • フロントエンドをこれから始めるなら、esbuildを直接触る代わりにViteを使うのが主流である(内部でesbuildを使っている)。今回は素のHTML+Canvasでバンドラーの機能がほぼ不要だったので、esbuild直で最小にした
  • tsx はNode用、ブラウザ用のバンドルはesbuildと、「どこで実行するか」で道具が変わる。同じ .ts がゲーム本体(ブラウザ)とシミュレーション(Node)の両方から使われており、ロジックを共有できるのはこの構成の恩恵だ

CIではこの4つ(lint・型・テスト・ビルド)をそのままステージにしている。組み方の詳細はCI/CDの記事に書いた。

まとめ — 型はテストより先に効く

移植してみての結論は、「一人開発でも型は割に合う」だ。理由は速度で、テストは書いて実行して初めてバグを教えてくれるが、型は書いた瞬間に教えてくれる。今回のCI/CDでは型チェックを独立ステージにしたので、PRを出すたびに全ファイルの形の整合が機械的に確認される。

言語仕様を網羅的に勉強してから書き始める必要はなかった。基礎3行と道具の役割分担、そして上の7つ — 特に「型は実行されない」「境界は自分で守る」の2つさえ押さえておけば、あとは書きながらで間に合う。


関連記事:

コメント

タイトルとURLをコピーしました