WAN NYAN CLINIC

SESSION 02 · 30分

予約の状態と遷移をモデル化する

予約の6状態をDiscriminated Unionで表し、許可されていない状態遷移を型で拒否します。

今回つくるもの

S1で確認した「受付済みだけが診察を開始できる」という条件を、状態型と遷移で表します。

このように、処理の前後を通して常に成立させる条件を、このセッションでは不変条件と呼びます。

追加したい機能

予約を「診察中にする」、「診察結果を記録する」、「会計する」、「キャンセルする」といった状態変更の機能を追加します。

素朴な実装の落とし穴

配布コードの遷移関数は、引数に Appointment 全体を受け取ります。そのため、Paid を startExamination へ渡すことも、診察結果を記録していない InExamination を recordPayment へ渡すこともコンパイルできます。実行時に requireKind が例外を投げるまで、状態の誤りに気づけません。cancel もキャンセル理由を undefined で受け取れるため、コンパイラではキャンセル理由が抜けていることを検知できません。

配布コードをもとにした例: examples/session-02/src/domain/appointment/transitions.ts
export const startExamination = (
  appointment: Appointment,
  veterinarianId: string,
  examinationStartedAt: string,
): Appointment => {
  requireKind(appointment, ["CheckedIn"]);
  return ({
    ...appointment,
    kind: "InExamination",
    veterinarianId,
    examinationStartedAt,
  }) as Appointment;
};

declare const paidAppointment: Paid;

// Paid を渡してもコンパイルできる
startExamination(
  paidAppointment,
  "vet-001",
  "2026-08-30T10:00:00+09:00",
);

declare const examining: InExamination;

// 診察結果を記録していなくてもコンパイルできる
recordPayment(
  examining,
  { diagnosis: "皮膚炎", treatment: "軟膏", amount: 4800 },
  "2026-08-30T10:30:00+09:00",
);

このセッションのゴール

Scheduled | CheckedIn | InExamination | AwaitingPayment | Paid | Canceled のDiscriminated Unionを、遷移関数と分岐でも一貫して使います。completeExamination は InExamination だけを受け取り、recordPayment は AwaitingPayment だけを受け取る純粋関数にします。分岐では網羅チェックを行い、状態を追加したときの未対応の分岐をコンパイルエラーにします。

このセッションで守ること

  • 診察結果を記録するまで会計できず、許可されていない状態遷移を型で拒否する。
  • 各状態において必要な情報の抜け漏れを許さない。
  • 状態を追加したとき、未対応の分岐をコンパイルエラーにする。

今回の変更は examples/session-02/src/domain/appointment 内の1モジュールに限定します。

コードを読み、失敗を再現する

Appointment 全体を受け取る遷移関数、throw、as Appointment、default 分岐を探します。これらは、許可されていない状態遷移や、状態追加時の未対応の分岐をコンパイラで止められない箇所です。

編集する範囲は examples/session-02/src/domain/appointment、変更するファイル数は最大 2ファイル・約35行です。

  1. current state を受け取る関数が Appointment 全体を受け入れている

    src/domain/appointment/transitions.ts:8-17
    const requireKind = (appointment: Appointment, allowedKinds: ReadonlyArray<Appointment["kind"]>): void => {
      if (!allowedKinds.includes(appointment.kind)) {
        throw new Error(`Cannot transition from ${appointment.kind}`);
      }
    };
    
    export const checkIn = (appointment: Appointment, checkedInAt: string): Appointment => {
      requireKind(appointment, ["Scheduled"]);
      return ({ ...appointment, kind: "CheckedIn", checkedInAt }) as Appointment;
    };
    src/domain/appointment/transitions.ts:19-49
    export const startExamination = (
      appointment: Appointment,
      veterinarianId: string,
      examinationStartedAt: string,
    ): Appointment => {
      requireKind(appointment, ["CheckedIn"]);
      return ({ ...appointment, kind: "InExamination", veterinarianId, examinationStartedAt }) as Appointment;
    };
    
    export const completeExamination = (
      appointment: Appointment,
      input: CompleteExaminationInput,
      examinationCompletedAt: string,
    ): Appointment => {
      requireKind(appointment, ["InExamination"]);
      return ({
        ...appointment,
        ...input,
        kind: "AwaitingPayment",
        examinationCompletedAt,
      }) as Appointment;
    };
    
    export const recordPayment = (
      appointment: Appointment,
      input: RecordPaymentInput,
      paidAt: string,
    ): Appointment => {
      requireKind(appointment, ["AwaitingPayment"]);
      return ({ ...appointment, ...input, kind: "Paid", paidAt }) as Appointment;
    };

    current state を型で絞らず、実行時の requireKind と throw に頼っています。 禁止したい状態でも呼び出し側のコードはコンパイルが通り、as で結果を押し通せます。

  2. current state の表示分岐が未知の状態を見逃す

    src/domain/appointment/statusLabel.ts:3-20
    export const toStatusLabel = (appointment: Readonly<{ kind: string }>): string => {
      switch (appointment.kind) {
        case "Scheduled":
          return "予約済み";
        case "CheckedIn":
          return "来院済み";
        case "InExamination":
          return "診察中";
        case "AwaitingPayment":
          return "会計待ち";
        case "Paid":
          return "会計済み";
        case "Canceled":
          return "キャンセル";
        default:
          return "不明";
      }
    };

    current state の kind を string として受け、default で不明を返します。 状態を追加しても、未対応の分岐がコンパイルエラーになりません。

修正前の失敗を確認する

修正前は、次の4件を確かめる5個の型検査が失敗します。

  • 会計済み・キャンセル済みでも診察を開始できる。
  • 理由を残さずに予約をキャンセルできる。
  • 診察結果の記録前や、許可されていない状態から操作できる。
  • 状態を追加しても、表示名の分岐漏れを検出できない。

失敗を確認する

pnpm exercise:02

期待結果: 5個の型検査が失敗します。

ブラウザ内の変更はローカルへ反映されません。

current state の要求と作成対象を開始 snapshot で確認します。

exercises/state-modeling.test.tsimport { describe, expectTypeOf, it } from "vitest";import type {  CheckedIn,  InExamination,  Paid,} from "../src/domain/appointment/appointment.js";import { toStatusLabel } from "../src/domain/appointment/statusLabel.js";import {  cancel,  checkIn,  recordPayment,  startExamination,} from "../src/domain/appointment/transitions.js";describe("Step 1", () => {  it("会計済みの来院から診察を開始できない", () => {    expectTypeOf<Paid>().not.toMatchTypeOf<Parameters<typeof startExamination>[0]>(); // 要件: 会計済みの来院から診察を開始できない型にしてください。  });});describe("Step 2", () => {  it("キャンセルには必ず理由を残す", () => {    expectTypeOf<undefined>().not.toMatchTypeOf<Parameters<typeof cancel>[1]>(); // 要件: キャンセル理由を省略できない型にしてください。  });});describe("Step 3", () => {  it("来院済みの予約を再度来院済みにできない", () => {    expectTypeOf<CheckedIn>().not.toMatchTypeOf<Parameters<typeof checkIn>[0]>(); // 要件: 来院済みの予約を再度来院済みにできない型にしてください。  });  it("診察結果を記録する前に会計できない", () => {    expectTypeOf<InExamination>().not.toMatchTypeOf<Parameters<typeof recordPayment>[0]>(); // 要件: 診察結果を記録する前に会計できない型にしてください。  });});describe("Step 4", () => {  it("未定義の予約状態を表示対象にできない", () => {    expectTypeOf<Readonly<{ kind: "Deferred" }>>().not.toMatchTypeOf<Parameters<typeof toStatusLabel>[0]>(); // 要件: 未定義の予約状態には表示名を付けられない型にしてください。  });});

事前知識(6分)

この回で利用する型について確認します。配布コードの書き方を before、次のスナップショットの書き方を after として並べています。

Discriminated Unionは共通のkindで型を絞り込める

Unionは、値が複数の型のうち、いずれか1つであることを表します。判別フィールドを持たないオブジェクトのUnionでは、固有のプロパティがあるかどうかを調べて型を絞り込むしかありません。Discriminated Unionは、すべてのオブジェクト型に共通の判別フィールドを置き、型ごとに異なるリテラル値を割り当てたUnionです。

before: 判別フィールドを持たないオブジェクトのUnion
type Appointment =
  | Readonly<{ scheduledAt: string }>
  | Readonly<{ scheduledAt: string; checkedInAt: string }>
  | Readonly<{
      scheduledAt: string;
      checkedInAt: string;
      examinationStartedAt: string;
    }>;

const toStatusLabel = (appointment: Appointment): string => {
  if ("examinationStartedAt" in appointment) return "診察中";
  if ("checkedInAt" in appointment) return "来院済み";
  return "予約済み";
};
after: kindを持つDiscriminated Union
type Appointment =
  | Readonly<{ kind: "Scheduled"; scheduledAt: string }>
  | Readonly<{
      kind: "CheckedIn";
      scheduledAt: string;
      checkedInAt: string;
    }>
  | Readonly<{
      kind: "InExamination";
      scheduledAt: string;
      checkedInAt: string;
      examinationStartedAt: string;
    }>;

const toStatusLabel = (appointment: Appointment): string => {
  switch (appointment.kind) {
    case "Scheduled": return "予約済み";
    case "CheckedIn": return "来院済み";
    case "InExamination": return "診察中";
  }
};

判別フィールドがない例では、checkedInAtやexaminationStartedAtの有無と、判定する順序に依存します。InExaminationにもcheckedInAtがあるため、先にcheckedInAtを調べると誤判定します。状態が増えるほど条件は複雑になります。共通のkindがあれば、switchでkindの値を確認するだけで各型へ絞り込めます。状態固有の必須情報を持てること自体は、どちらのUnionでも同じです。違いは、どの型かを判別する共通フィールドがあるかどうかです。

遷移関数の引数型で、受け取れる状態を限定する

Discriminated Unionから特定の状態型を選び、遷移関数の引数に指定します。許可していない状態は、関数を呼び出す時点でコンパイルエラーになります。

before: examples/session-02/src/domain/appointment/transitions.ts
export const startExamination = (
  appointment: Appointment,
  veterinarianId: string,
  examinationStartedAt: string,
): Appointment => {
  requireKind(appointment, ["CheckedIn"]);
  return ({ ...appointment, kind: "InExamination", veterinarianId, examinationStartedAt }) as Appointment;
};

export const recordPayment = (
  appointment: Appointment,
  input: RecordPaymentInput,
  paidAt: string,
): Appointment => {
  requireKind(appointment, ["AwaitingPayment"]);
  return ({ ...appointment, ...input, kind: "Paid", paidAt }) as Appointment;
};
after: examples/session-03/src/domain/appointment/transitions.ts
export const startExamination = (
  appointment: CheckedIn,
  veterinarianId: string,
  examinationStartedAt: string,
): InExamination =>
  ({
    ...appointment,
    kind: "InExamination",
    veterinarianId,
    examinationStartedAt,
  }) as const satisfies InExamination;

export const completeExamination = (
  appointment: InExamination,
  input: CompleteExaminationInput,
  examinationCompletedAt: string,
): AwaitingPayment =>
  ({ ...appointment, ...input, kind: "AwaitingPayment", examinationCompletedAt })
    as const satisfies AwaitingPayment;

export const recordPayment = (
  appointment: AwaitingPayment,
  input: RecordPaymentInput,
  paidAt: string,
): Paid =>
  ({ ...appointment, ...input, kind: "Paid", paidAt }) as const satisfies Paid;

before は Appointment 全体を受け取るため、startExamination へ Paid や Canceled を渡せ、recordPayment へ InExamination を渡せます。状態の誤りが分かるのは、実行時に requireKind が例外を投げたときです。after は遷移ごとに CheckedIn、InExamination、AwaitingPayment だけを受け取ります。会計済みの予約や診察結果を記録していない予約を誤った関数へ渡すと、コードを実行する前の型検査で止まります。Discriminated Unionが状態の候補を定義し、関数の引数型が許可された遷移を定義します。

assertNeverは未処理の状態をコンパイルエラーにする

kindで分岐してAppointmentの全状態を処理すると、default内のappointmentはnever型になります。assertNeverはnever型だけを受け取るため、未処理の状態が残っていないことを型検査で確かめられます。

before: examples/session-02/src/domain/appointment/statusLabel.ts
export const toStatusLabel = (appointment: Readonly<{ kind: string }>): string => {
  switch (appointment.kind) {
    case "Scheduled":
      return "予約済み";
    case "CheckedIn":
      return "来院済み";
    case "InExamination":
      return "診察中";
    case "AwaitingPayment":
      return "会計待ち";
    case "Paid":
      return "会計済み";
    case "Canceled":
      return "キャンセル";
    default:
      return "不明";
  }
};
after: examples/session-03/src/domain/appointment/statusLabel.ts
const assertNever = (value: never): never => {
  throw new Error(`Unknown appointment status: ${JSON.stringify(value)}`);
};

export const toStatusLabel = (appointment: Appointment): string => {
  switch (appointment.kind) {
    case "Scheduled":
      return "予約済み";
    case "CheckedIn":
      return "来院済み";
    case "InExamination":
      return "診察中";
    case "AwaitingPayment":
      return "会計待ち";
    case "Paid":
      return "会計済み";
    case "Canceled":
      return "キャンセル";
    default:
      return assertNever(appointment);
  }
};
NoShowを追加したのに、表示名の分岐を追加し忘れた例
// もし後から Appointment に "NoShow"(無断キャンセル)を追加した場合...
switch (appointment.kind) {
  case "Scheduled":
    return "予約済み";
  case "CheckedIn":
    return "来院済み";
  case "InExamination":
    return "診察中";
  case "AwaitingPayment":
    return "会計待ち";
  case "Paid":
    return "会計済み";
  case "Canceled":
    return "キャンセル";
  // ❌ case "NoShow": の実装を忘れると...
  default:
    // ここで appointment は NoShow 型と推論される
    // エラー: 型 'NoShow' の引数を型 'never' のパラメーターに
    // 割り当てることはできません
    return assertNever(appointment);
}

before は未知の文字列を「不明」として受け入れるため、状態を追加しても分岐漏れに気づけません。after で現在の全状態を処理すると、default内のappointmentはneverになります。AppointmentにNoShowを追加し、caseを足し忘れると、defaultに残る型はNoShowです。NoShowはassertNeverへ渡せないため、コンパイルエラーになります。同じ網羅性チェックを使う分岐でも修正漏れがエラーになるので、新しい状態の影響箇所をコンパイラが示します。

演習

進め方: 言語化 → Agentへの依頼 → 検証

  1. 言語化(2分): 起きてはいけない状態遷移を1文で書く。
  2. 依頼(9分): その1文と自分の判断をテンプレートへ入れ、Agent に依頼する。
  3. 検証(2分): 型、テスト、差分の範囲を確認する。

プロンプトのテンプレート

角括弧の中は、事故と配布コードを読んだ自分の判断で埋めます。技法名や完成形を先に指定する必要はありません。

次の変更を実装してください。

業務背景:
診察結果を記録していない予約が会計され、さらに会計済みの来院が診察中へ戻された。

依頼:
予約状態の変更処理を、業務で許された遷移と各状態に必要な情報がコードから読み取れる形に改善してください。

起きてはいけない状態遷移: [言語化フェーズで書いた1文]

着手前に判断すること:
- どの状態からどの状態への遷移を許可するか: [自分の判断を書く]
- 各状態で必須にする情報は何か: [自分の判断を書く]
- 状態追加時の分岐漏れをどこで検出するか: [自分の判断を書く]

まず配布コードと失敗しているテストを読み、上の判断と変更方針を短く説明してください。説明のあとで実装に進んでください。設計手段は既存コードに合うものを選び、選んだ理由も示してください。

受け入れ条件:
- 会計済み・キャンセル済みの来院は診察を開始できないようにする。
- キャンセルには必ず理由を残す。
- 診察結果の記録前には会計できないようにし、残りの遷移も許可された遷移元だけを受け取る規約にそろえる。
- 状態を追加したら表示名の分岐をコンパイルエラーにする。

変更範囲:
- examples/session-02/src/domain/appointment/ のみ
- 最大 2 ファイル・約 35 行
- 範囲外の変更が必要に見えた場合は、変更せず理由を報告する
- 型エラーを `as` によるキャストで回避しない

検証:
- pnpm exercise:02 を実行し、すべての assertion が成功すること

完了時の報告:
- 変更したファイルと判断理由
- 検証コマンドの結果
- 型だけでは守れず、テストまたはレビューに残した点

この演習で解決しないこと

この型が守れるのは、TypeScript が検査できる値だけです。外部から届く未検証の値や、型アサーションで作った値は防げません。

ステップごとの解答

会計済み・キャンセル済みの来院は診察を開始できないようにする。

examples/session-03/src/domain/appointment/transitions.ts

export const startExamination = (
  appointment: CheckedIn,
  veterinarianId: string,
  examinationStartedAt: string,
): InExamination =>
  ({
    ...appointment,
    kind: "InExamination",
    veterinarianId,
    examinationStartedAt,
  }) as const satisfies InExamination;
キャンセルには必ず理由を残す。

examples/session-03/src/domain/appointment/transitions.ts

export const cancel = (
  appointment: Scheduled | CheckedIn,
  reason: CancellationReason,
  canceledAt: string,
): Canceled =>
  ({
    kind: "Canceled",
    appointmentId: appointment.appointmentId,
    petId: appointment.petId,
    ownerId: appointment.ownerId,
    scheduledAt: appointment.scheduledAt,
    reason,
    canceledAt,
  }) as const satisfies Canceled;
診察結果の記録前には会計できないようにし、残りの遷移も許可された遷移元だけを受け取る規約にそろえる。

examples/session-03/src/domain/appointment/transitions.ts

export const checkIn = (appointment: Scheduled, checkedInAt: string): CheckedIn =>
  ({ ...appointment, kind: "CheckedIn", checkedInAt }) as const satisfies CheckedIn;

export const startExamination = (
  appointment: CheckedIn,
  veterinarianId: string,
  examinationStartedAt: string,
): InExamination =>
  ({
    ...appointment,
    kind: "InExamination",
    veterinarianId,
    examinationStartedAt,
  }) as const satisfies InExamination;

export const completeExamination = (
  appointment: InExamination,
  input: CompleteExaminationInput,
  examinationCompletedAt: string,
): AwaitingPayment =>
  ({
    ...appointment,
    ...input,
    kind: "AwaitingPayment",
    examinationCompletedAt,
  }) as const satisfies AwaitingPayment;

export const recordPayment = (
  appointment: AwaitingPayment,
  input: RecordPaymentInput,
  paidAt: string,
): Paid =>
  ({ ...appointment, ...input, kind: "Paid", paidAt }) as const satisfies Paid;
状態を追加したら表示名の分岐をコンパイルエラーにする。

examples/session-03/src/domain/appointment/statusLabel.ts

const assertNever = (value: never): never => {
  throw new Error(`Unknown appointment status: ${JSON.stringify(value)}`);
};

export const toStatusLabel = (appointment: Appointment): string => {
  switch (appointment.kind) {
    case "Scheduled":
      return "予約済み";
    case "CheckedIn":
      return "来院済み";
    case "InExamination":
      return "診察中";
    case "AwaitingPayment":
      return "会計待ち";
    case "Paid":
      return "会計済み";
    case "Canceled":
      return "キャンセル";
    default:
      return assertNever(appointment);
  }
};

効果を確認する

pnpm exercise:02

期待結果: 演習テストがすべて成功します。

演習を完了できないとき

解答スナップショットをステップ単位で開き、まず遷移関数の引数型、その後に網羅分岐をコピペします。相互レビューは行いましょう。

レビューと持ち帰り

個人で確認する

  1. `as` によるキャストが入っていないか全文検索して確認する。
  2. `git diff --stat -- examples/session-02` で今回の snapshot だけを確認する。`git status --short` で想定外の path がないか確認する。
  3. 型検査では確認できないことを、テストまたは実行時に確認して記録する。

前のセッションの未commit差分は残して構いません。reset、stash、commit は不要です。

残すもの: 起きてはいけない状態遷移、Agentへの依頼文、型検査では確認できず、テストまたは実行時に確認すること

業務へ持ち帰る

自分の業務コードで、今回と同種の問題が起きうる箇所はどこですか。

相互レビューの進行上の約束事

  1. 人ではなく差分を見ます。「この差分は」で話し始め、優劣をつけません。
  2. 本人はAgentへの依頼文だけを読み上げ、弁明しません。
  3. TAは、同じ課題に対して設計判断が異なる差分を選びます。完成度や技能による選出ではありません。
  4. 5回で班員全員を少なくとも1回選びます。

班内相互レビュー(7分・1〜2名)

進行上の約束事を確認する

  1. `startExamination` は `CheckedIn` だけを受け取り、会計済み・キャンセル済みを型で拒否しますか。
  2. `recordPayment` は `AwaitingPayment` だけを受け取り、診察結果の記録前には会計できない型ですか。
  3. 状態を追加したとき、`assertNever` によって未対応の分岐がコンパイルエラーになりますか。