TypeScript コーディング規約

機械的に強制できる整形・静的検査は Prettier・ESLint・tsc --strict(npm run lint / npm run typecheck)に 任せる。ここには機械化できない規約だけを書く。設計方針(カプセル化・コメントの書き方)は CLAUDE.md を参照。

ファイルと命名

型と値

グローバルIDは、名前空間ごとに別の型

名前空間(NameRegistry 1つぶん)が配るグローバルIDは、number に型の上だけの印を交ぜた GlobalId<名前空間> で受ける。実体は素の number のままなので、 比較もMapの鍵も配列の添字もそのまま使える。名前空間が違えば別の型になり、プロパティのIDを受ける宣言へ スロットのIDや素の数を渡すと型で止まる(tests/architecture/globalId.test.ts が @ts-expect-error で見張る。受け口が number へ戻ると npm run typecheck が赤くなる)。

WorldCodex が持つ名前空間はどれも分かれている。 別名は GlobalId.ts に並ぶ。とくにタグは 2つの名前空間に分かれる——型のタグ(GameElementDefinition.md 4.1節、tagNames)とプロパティのタグ(同 6.7節、 propertyTagNames)で、取り違えても番号としては通り、「そのタグを持たない」が静かに返る。

素の number から専用の型へ変わるのは NameRegistry の中だけ。 番号を決めているのが intern の1行で、外へ開いているのは名前から引く経路(intern/getId/tryGetId)しか無い。 YAMLもシナリオも生成物もURLも名前で越境するので、外から来た数がIDとして通ることがない。

IDの並びが要る側は NameRegistry.ids を読む。count まで添字を数えると、そこが素の数からIDを 組み立てる場所になる(型の一覧なら ObjectDefTable の走査。名前だけ登録されて定義の無いIDを飛ばす)。

IDを型引数で受けるクラスは in out(不変)で宣言する。外すと、種類の付いた名前空間を素の number の名前空間として扱えてしまう——メソッドの引数は既定では双変なので、 種類を選んで引く と書いた三項の型が NameRegistry<number> へ落ち、そこから先は素の数が通る。 素の number の側へ広げられないことは、そう代入して @ts-expect-error で止める形で tests/architecture/globalId.test.ts が見張る (広がった瞬間に代入が通り、余った @ts-expect-error が npm run typecheck を赤くする)。 in out はそれを保つ手段のひとつで、見張っているのは広がらないことのほう。

名前空間を1つ増やすときの手順:

  1. GlobalId.ts に GlobalId<'その名前'> の別名を置く。
  2. 配る側の NameRegistry をその別名で型付けする(WorldCodex・WorldCodexYamlLoader の フィールドとゲッター)。
  3. npm run typecheck が挙げる箇所を追う。明示的に number と書いてある宣言だけが残る ——intern から受けた値をそのまま渡している経路は推論で埋まる。
  4. 名前空間を実行時に選んでいる箇所(kind で NameRegistry を選ぶ三項など)は、不変にしてある ぶんここで型エラーになる。選ぶのをやめて、名前空間を知っている側が引く形へ直す (WorldObject.notFoundMessage)。

跨ぐ場所を新しく作らなければならないなら、跨ぐ理由をその場に書く。既にある例外は試験だけで、 どれも名前空間が配っていない番号を自分で作るもの(配っていないIDを渡して確かめる、あるいは Codexを持たずに組み立てだけを見る)。as <種類>GlobalId を grep すれば在り処が全部挙がる。 src/ に生えていないことは tests/architecture/globalId.test.ts が見張る ——読み替えは型の上だけで済むので、型検査では気づけない。

プロパティの「値」に別の名前空間のIDが入る(型を値に持つプロパティは objectNames のID、 シンボル型は symbolNames のID。GameElementDefinition.md 6.6節・6.9節)。値は著者が書いた数であって 名前空間が配ったIDではないので、型の上では繋がっていない。読み替えるのは GlobalId.ts の objectGlobalIdOfPropertyValue・symbolGlobalIdOfPropertyValue で、値をIDとして 読む経路はこの2つに寄せる。書き込む側は越境ではない——ローダは名前から NameRegistry で引いて、 そのIDを値として置くだけで、印を捨てる向きは型が支える。

IDは生成物(stats/*.yaml)へ出さない。 番号は読み込むたびに振り直されるので、定義を1つ足す だけで無関係な行が全部動く。書き出す値の型がIDを受け付けない形 (GlobalId.ts の NotAGlobalId)になっているので、混ぜると npm run typecheck が赤くなる。出したいなら書き出す手前で名前へ戻す。

クラス

エラー

import

ファイルの読み込み

コメント

テスト

export は「この名前は外から使う」という宣言なので、どこからも輸入されない const・function は tests/architecture/exports.test.ts が見張る(型とクラスは、輸入されなくても署名で名乗るために公開する 値打ちがあるので見ない)。

種類ごとに分ける

赤が出た瞬間にどこを見に行くかが決まるように、テストは種類で置き場を分ける。境目は tests/architecture/testKinds.test.ts が見張る。

種類 置き場 赤の意味
層の責務 他の行のどれでもない置き場(tests/domain・tests/game・tests/loader ほか) その層のコードが壊れた
通し tests/integration 層の繋ぎ目が壊れた
同梱の中身 testKinds.test.ts の BUNDLED_CONTENT が持つ置き場 同梱のYAML・絵・対応表を直した副作用

層の責務のテストは同梱の定義(src/assets/world-codex)を読まない。 読むと、YAMLを直しただけで その層が赤くなり、赤の読み方が決まらない。確かめたい形はそのテストの中にYAMLで宣言する——1つの テストが読むぶんだけを、そのテストの隣に書く(テスト間で1つの大きな定義を共有しない)。

映しの層は入口が StartedGame なので、tests/support/miniGame.ts が地形生成を通さない一式を組み立てる。時間を進めるだけなら tests/support/worldYaml.ts の world を読む。

通しのテストは実データとrngの引きに依存してよい。何を前提にしているかは冒頭に書く。