TypeScript コーディング規約
機械的に強制できる整形・静的検査は Prettier・ESLint・tsc --strict(npm run lint / npm run typecheck)に
任せる。ここには機械化できない規約だけを書く。設計方針(カプセル化・コメントの書き方)は
CLAUDE.md を参照。
ファイルと命名
- どのフォルダへ置くかは、そのファイルが答えることで決まる(
CodeStructure.md1節)。 - ファイル名は主要エクスポートの名前に一致させる。クラス・型が主ならPascalCase(
PropertyValue.ts)、 関数群のモジュールならcamelCase(yamlMapping.ts)。 - ディレクトリ名は小文字で、複数語はケバブケース(
domain/generation・asset-pack・assets/world-codex)。 ファイル名と違い指す識別子が無いので、綴りを合わせる相手がいない。データのファイル名(src/assets/以下)だけは例外で、識別子と同じsnake_caseに揃える (GameElementDefinition.md3.2節)。 - 1ファイル1責務。1つのクラスを複数ファイルへ分割しない(大きくなりすぎる場合は 協力クラス・関数モジュールへ切り出す)。
- クラス・型・インターフェース: PascalCase。メソッド・プロパティ・変数: camelCase。 モジュールスコープの定数: UPPER_SNAKE_CASE。
- 失敗しうることは名前に出す。 無いときに投げるものは
get、無いときにundefinedを返すものはtryを頭に付ける(WorldObject.getSlotとtryGetSlot)。 - 単位や軸が値の一部なら、名前に持たせる(
travelMinutes)。読み手は中身ではなく名前を信じるので、 名前が個数を指していて中身が分、のような取り違えはそのまま呼び出し側へ伝わる。 - 兄弟が既に持っている語形へ寄せる。 1つだけ改名するときに新しい規則を作らない——揃っていない 兄弟が残ると、どちらが規約なのかを読み手が決められなくなる。
privateかどうかで基準を緩めない。 外から呼ばれないぶん壊れる範囲は狭いが、読む人を誤解させる 量は変わらない。
型と値
- 「値が無い」は
undefinedで表す。nullは使わない(外部ライブラリが返す場合は境界で変換する)。 enumは使わず、文字列リテラルユニオンを使う(type Kind = 'modify' | 'add')。- ドメインの数値の値域は32bit整数の幅(
GameElementDefinition.md6節)。除算・小数→整数変換はMath.truncを明示する(JSの/は常に浮動小数点になるため)。 - 公開APIで返すコレクションは
readonly T[]/ReadonlyMapにする。内部の可変リストは配列、 キー付きコレクションは挿入順が保証されるMapを使う。 - 条件で値を選ぶだけの分岐は
if (a) return x; return y;ではなくreturn a ? x : y;と書く。 処理の分岐ではなく値の分岐なので、形が中身と一致する(短さは理由ではない)。
グローバル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つ増やすときの手順:
GlobalId.tsにGlobalId<'その名前'>の別名を置く。- 配る側の
NameRegistryをその別名で型付けする(WorldCodex・WorldCodexYamlLoaderの フィールドとゲッター)。 npm run typecheckが挙げる箇所を追う。明示的にnumberと書いてある宣言だけが残る ——internから受けた値をそのまま渡している経路は推論で埋まる。- 名前空間を実行時に選んでいる箇所(
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 が赤くなる。出したいなら書き出す手前で名前へ戻す。
クラス
- getterの背後にある可変フィールドだけ
_プレフィックスを付ける(private _numberとget number())。 それ以外のフィールドにプレフィックスは付けない。 - 可視性は
private/readonlyキーワードで表す(#フィールドは使わない)。
エラー
- 例外は
Errorの派生クラスで表し、クラス名は〜Errorとする。メッセージは日本語。
import
- 相対パスで書く(パスエイリアスは使わない)。型としてしか使わないものは
import type。 循環参照が必要な相互再帰型(例: 定義と実行時状態)はimport typeに限って許す。
ファイルの読み込み
- リポジトリのファイルを読んで行に割るときは
\rを落とす(split(/\r?\n/))。作業ツリーは CRLF なので、split('\n')だと行末に\rが残り、行まるごとを比較する処理が Linux では通って Windows でだけ外れる。
コメント
- TSDoc(
/** … */)で書く。言語は日本語。何を書いてよいかはCLAUDE.mdの コメント方針に従う。@param/@returnsの羅列はしない(本文で足りる説明を優先する)。 - 説明が要ると感じたら、第一手は改名。 名前にしてなお言い残す契約があるときだけコメントを足す。 コメントが無いこと自体は欠陥ではない——名前が責務を言い切っていれば、それが良い状態。
- 説明は、それが説明している宣言自身に付ける。 TSDoc は直後の1宣言の説明として道具が出すので、 1つずらして書くと、読み手も IDE も別の宣言の説明として読む。
テスト
- Vitest を使う。
describeはクラス・機能単位、itの説明は挙動を平叙文で書く (it('rangeの下限に達するとon_minが発火する', …))。 - 乱数に依存する挙動のテストは、実装のシード列に依存させず、意図した値列を返すスタブ
Rngを渡して シナリオを明示する。「同じシード→同じ結果」の再現性だけを確認するテストはシード付き実装を使ってよい。 - 字面を差し替えて入力を作るときは、当たった数を確かめる
(
tests/support/textEdit.tsのreplaceAllOrFail)。当たらない 差し替えは黙って元の文字列を返すので、確かめたい主張とは別の面を見たまま緑になる。 このうち機械で止まるのは、改行を含む字面で差し替える形だけ (tests/architecture/textEdits.test.ts。作業ツリーの改行コードで結果が変わるため、字面で見分けば 足りる)。改行を含まない差し替えは規則だけで、破っても落ちるものは無い。
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の引きに依存してよい。何を前提にしているかは冒頭に書く。