
はじめに
月額課金のSaaSで、まだ発売していないプランの料金が画面に表示されてしまう不具合に遭遇しました。
原因は、料金定数のオブジェクトに「とりあえずの仮の金額」を入れたままにしていたことです。
UI側は「値がある=確定している」と解釈して、仮の金額をそのまま表示していました。
さらに、その仮の金額は企画ドキュメントに書かれた「構想中の価格」とも食い違っていました。
画面とドキュメントで、異なる金額が同時に存在していたことになります。
この記事では、未定を「キーが存在しない」ことで表現し、型とテストで再発を防ぐ方法をコード付きで整理します。
こんな人におすすめ
- 複数プランを持つSaaSの料金表示を、TypeScript で実装している方
- 「仮の値」を定数に入れたまま、UIに流れてしまった経験がある方
- 画面表示用の定数と、決済側の正しい金額の二重管理に不安がある方
- 「表示されないこと」を確認するE2Eテストの書き方を知りたい方
仮の値は「未定」を表現できない
最初の状態は、次のような料金定数でした。
export const PLAN_PRICING = {
guardian: { monthly: 980, yearly: 9_800 },
team: { monthly: 9_800, yearly: 98_000 },
coach: { monthly: 3_800, yearly: 38_000 },
} as const
team と coach は未発売で、金額も決まっていません。
それでも仮の数字が入っているため、UIは何も疑わずに表示します。
数値型は「未定」を表せません。
どれだけUI側で注意しても、データの形が「確定済み」に見えている限り、事故は起きます。
キーを消して「存在しない=料金未定」にする
対応は単純で、未発売プランのキーごと削除しました。
export const PLAN_PRICING = {
guardian: { monthly: 980, yearly: 9_800 },
} as const
「発売と価格決定が済んだプランだけがキーを持つ」というルールを、オブジェクトの形そのもので表現しています。
型で「null かもしれない」を強制する
キーを消すだけでは、参照側が PLAN_PRICING[plan] と書いたときの扱いが曖昧です。
そこで Partial<Record<...>> として受け、未定プランへのアクセスを null になり得る値として扱います。
// 未発売プランは発売・価格決定まで PLAN_PRICING にキーを持たない。
// Partial<Record<...>> にすることで、未定プランへのアクセスは
// コンパイル時に「undefined かもしれない値」として扱われる。
const PLAN_PRICING_BY_PLAN: Partial<Record<PlanKey, { monthly: number; yearly: number }>> =
PLAN_PRICING
const pricing = PLAN_PRICING_BY_PLAN[plan] ?? null
表示側では、必ず両方の分岐を書くことになります。
{pricing ? (
<>
<span>月額</span>
<strong>{formatPrice(pricing.monthly)}</strong>
<small>年額 {formatPrice(pricing.yearly)}</small>
</>
) : (
<strong>料金未定</strong>
)}
この形には、plan === 'guardian' のようなプラン名のハードコード分岐を増やさずに済むという利点もあります。
新しいプランの価格が決まったら、定数にキーを足すだけで表示が切り替わります。
正本が二段階あるときは、両方に注記を残す
今回の構成では、金額の持ち場所が二つありました。
- 実際に課金される金額:決済プロバイダ側の price
- 画面表示用のミラー定数:コード内の
PLAN_PRICING
片方だけ更新して同期を忘れる事故は、この構造では起きやすいです。
そのため、コードコメントと設計ドキュメントの両方に次の内容を明記しました。
- どちらが正本か
- 表示用定数に、いつキーを追加してよいか
後から別の担当者が触っても迷わないようにするための対策です。
「ないこと」を検証するテストを書く
回帰テストは、金額の値ではなくキーの集合を検証します。
金額をハードコードすると、価格改定のたびにテストの修正が必要になるためです。
it('PLAN_PRICING は発売済みプランのみキーを持つ(プレースホルダ値の復活を検知する)', () => {
expect(Object.keys(PLAN_PRICING)).toEqual(['guardian'])
expect('coach' in PLAN_PRICING).toBe(false)
expect('team' in PLAN_PRICING).toBe(false)
})
E2Eテストでは、未定プランの画面に金額らしき文字列が出ていないことを確認します。
test('未定プランのページには具体的な金額が一切出ない', async ({ page }) => {
await gotoAppPath(page, '/pricing?plan=coach')
await expect(page.getByText('コーチプラン: 料金未定')).toBeVisible()
await expect(page.getByText(/月額\s*[\d,]+\s*円/)).toHaveCount(0)
await expect(page.getByText(/年額\s*[\d,]+\s*円/)).toHaveCount(0)
})
「正しい表示が出ること」だけでなく、「間違った表示が出ないこと」を明示的に確認するのがポイントです。
ついでに、既存のフォーマット関数に 0 を渡したときの挙動もテストに追加しました。
既存コードに手を入れるタイミングは、隣にある未テストの境界値を拾う良い機会になります。
つまづきやすいポイント
as constのオブジェクトを直接インデックスすると型エラーになる: 未定義キーでのアクセスはそのままでは通りません。Partial<Record<PlanKey, ...>>に代入し直して参照します。PlanKeyの型にだけ未発売プランが残る: プラン名の型は残したまま、価格定数からだけキーを消す設計です。型と定数を同時に消さないよう注意してください。- 正規表現のネガティブアサーションが緩すぎる: 金額のフォーマットが変わると、
/月額\s*[\d,]+\s*円/をすり抜けることがあります。表示形式を変えたときは、テスト側の正規表現も見直します。 - ドキュメントの更新漏れ: コードだけ直しても、企画ドキュメントの金額が残っていれば矛盾は消えません。正本の所在を明記するところまでが修正の範囲です。
まとめ
仮の数値は「未定」を表現できず、UIはそれを確定値として表示してしまいます。
未定は「キーが存在しない」ことで表し、Partial<Record<...>> と ?? null で参照側に分岐を強制すると安全です。
テストは金額ではなくキーの集合と「金額が出ていないこと」を確認すると、価格改定に強い回帰テストになります。
正本が複数ある構造では、コードとドキュメントの両方に「どちらが正本か」を残してください。

