WAN NYAN CLINIC

SESSION 03 · 30分

診察開始の識別子を型で区別する

診察開始で使う予約IDと担当獣医師IDを、取り違えられない型にします。

今回つくるもの

S1で「どの予約を、どの獣医師が開始するか」と決めた入力を、用途別の型で表します。

追加したい機能

診察開始の入力にある予約IDと担当獣医師IDを、用途ごとに区別して扱う。

素朴な実装の落とし穴

AppointmentIdとVeterinarianIdは、どちらもUUID検査済みのstringです。startExaminationへ逆の順番で渡しても、型検査では止まりません。

このセッションのゴール

2つの識別子を別々のBranded Typeで定義し、予約状態とstartExaminationまで同じ型を使う。

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

  • AppointmentIdとVeterinarianIdを取り違えない。
  • 予約はどの状態でもAppointmentIdを持つ。
  • startExaminationはVeterinarianIdを受け取る。

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

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

最初に、AppointmentIdとVeterinarianIdを実際に逆の用途へ渡しているコードを読みます。そのうえで、2つの識別子にbrandがなく、予約状態とstartExaminationにもstringが残っていることを確認します。

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

  1. 予約IDと担当獣医師IDを逆の用途へ渡している

    src/domain/domain.test-types.ts:4-10
    const appointmentId = AppointmentId.parse(clinicFixture.appointmentId);
    const veterinarianId = VeterinarianId.parse(clinicFixture.veterinarianId);
    const acceptAppointmentId = (_id: AppointmentId): void => undefined;
    const acceptVeterinarianId = (_id: VeterinarianId): void => undefined;
    
    acceptAppointmentId(veterinarianId);
    acceptVeterinarianId(appointmentId);

    AppointmentIdを必要とする関数へVeterinarianIdを、VeterinarianIdを必要とする関数へAppointmentIdを渡しています。 どちらの型も実体はstringなので、この2行があっても型検査は成功します。

  2. UUIDを検査しても、型検査では予約IDと担当獣医師IDを区別できない

    src/domain/appointment/appointmentId.ts:3-6
    const schema = z.string().uuid();
    
    export type AppointmentId = z.infer<typeof schema>;
    export const AppointmentId = { schema, parse: schema.parse } as const;

    AppointmentIdとVeterinarianIdはuuid検査だけで、用途を表すbrandがありません。PetId、OwnerId、ExamIdにはbrandがあります。 予約IDを担当獣医師IDの位置へ渡しても、どちらもstringなのでコンパイルは通ります。

  3. 予約状態とstartExaminationがstringを受け取っている

    src/domain/appointment/appointment.ts:8-14
      kind: "Scheduled";
      appointmentId: string;
      petId: PetId;
      ownerId: OwnerId;
      scheduledAt: string;
      reason: string;
    }>;

    予約のappointmentIdと、startExaminationのveterinarianIdがstringで宣言されています。 予約IDと担当獣医師IDを入れ替えても検出できません。

取り違えたまま型検査する

acceptAppointmentIdacceptVeterinarianId の呼び出しは、渡す値が逆です。この状態で型検査を実行します。

型検査コマンド
pnpm --filter @fp-with-ts/clinic-session-03 typecheck

期待結果: 取り違えた2行が残っていても、型検査は成功します。

型検査が成功したこと自体が失敗の再現です。現在の AppointmentIdVeterinarianId は、どちらもUUID検査済みの string なので、TypeScriptには用途の違いが見えません。

修正前の失敗を確認する

修正前は、次の3件に対応する演習テストが失敗します。

  • 予約IDと担当獣医師IDを取り違えても、型検査が通る。
  • 予約状態とstartExaminationが、用途を区別しないstringを受け取る。
  • 取り違えた2行が、型エラーにならないままコンパイルされる。

失敗を確認する

pnpm exercise:03

期待結果: 3件の演習テストが失敗します。

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

診察開始で使う予約IDと獣医師IDを、開始snapshotで確認します。

exercises/semantic-identifiers.test.tsimport { describe, expect, expectTypeOf, it } from "vitest";import { startExamination } from "../src/domain/appointment/index.js";import { AppointmentId } from "../src/domain/appointment/index.js";import type { AppointmentId as AppointmentIdValue } from "../src/domain/appointment/index.js";import type { VeterinarianId } from "../src/domain/appointment/index.js";describe("Step 1", () => {  it("AppointmentId を VeterinarianId の用途へ渡せない", () => {    expectTypeOf<AppointmentIdValue>().not.toMatchTypeOf<VeterinarianId>(); // 要件: AppointmentId を VeterinarianId の用途へ渡せない型にしてください。  });});describe("Step 2", () => {  it("診察開始には VeterinarianId が必要", () => {    expectTypeOf<AppointmentIdValue>().not.toMatchTypeOf<Parameters<typeof startExamination>[1]>(); // 要件: 診察開始の担当獣医師に AppointmentId を渡せない型にしてください。  });});describe("Step 3", () => {  it("VeterinarianId を AppointmentId の用途へ渡せない", () => {    expectTypeOf<VeterinarianId>().not.toMatchTypeOf<AppointmentIdValue>(); // 要件: VeterinarianId を AppointmentId の用途へ渡せない型にしてください。  });});describe("回帰条件: 識別子は形式検査を通った値からしか作れない", () => {  it("UUIDでない文字列からAppointmentIdを作れない", () => {    expect(() => AppointmentId.parse("not-a-uuid")).toThrow();  });});

事前知識(6分)

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

用途別の Branded Type

同じ文字列形式でも、用途の違う識別子は別の型として扱います。zod の brand を付けると、互いに代入できない型ができます。

before: examples/session-03/src/domain/appointment/appointmentId.ts
const schema = z.string().uuid();

export type AppointmentId = z.infer<typeof schema>;
export const AppointmentId = { schema, parse: schema.parse } as const;
after: examples/session-04/src/domain/appointment/appointmentId.ts
const schema = z.string().uuid().brand<"AppointmentId">();

export type AppointmentId = z.infer<typeof schema>;
export const AppointmentId = { schema, parse: schema.parse } as const;

AppointmentIdとVeterinarianIdは、どちらもUUID形式のstringです。uuid検査だけでは相互に代入できます。演習ではPetId、OwnerId、ExamIdの実装を手本にして、それぞれ別のbrandを付けます。区別するのは値の形式ではなく用途です。

識別子をドメインモデルと状態遷移で利用する

予約の状態を表す型・状態遷移関数を、作成した Branded Type を利用する形に変更します。

before: examples/session-03/src/domain/appointment/appointment.tsexamples/session-03/src/domain/appointment/transitions.ts
export type Scheduled = Readonly<{
  kind: "Scheduled";
  appointmentId: string;
  // ...
}>;

export const startExamination = (
  appointment: CheckedIn,
  veterinarianId: string,
  examinationStartedAt: string,
): InExamination =>
after: examples/session-04/src/domain/appointment/appointment.tsexamples/session-04/src/domain/appointment/transitions.ts
export type Scheduled = Readonly<{
  kind: "Scheduled";
  appointmentId: AppointmentId;
  // ...
}>;

export const startExamination = (
  appointment: CheckedIn,
  veterinarianId: VeterinarianId,
  examinationStartedAt: string,
): InExamination =>

識別子型を定義しただけでは、予約状態と遷移関数がstringを受け取るままです。予約の6状態にあるappointmentIdをAppointmentIdへ変え、startExaminationの引数とInExaminationのveterinarianIdをVeterinarianIdへ変えます。入力から状態遷移まで同じ型を使うと、取り違えを型検査で止められます。

@ts-expect-error による型テスト

わざとコンパイルエラーになるコードを書き、その行で確かにコンパイルエラーが発生することを検査します。もし誰かが実装を変更して意図せずコンパイルエラーが出なくなってしまった場合、@ts-expect-error は「不要なエラー抑制を行なっている」ことになり、逆にコンパイルエラーとなります。

before: examples/session-03/src/domain/domain.test-types.ts
const acceptAppointmentId = (_id: AppointmentId): void => undefined;
const acceptVeterinarianId = (_id: VeterinarianId): void => undefined;

acceptAppointmentId(veterinarianId);
acceptVeterinarianId(appointmentId);
after: examples/session-04/src/domain/domain.test-types.ts
const acceptAppointmentId = (_id: AppointmentId): void => undefined;
const acceptVeterinarianId = (_id: VeterinarianId): void => undefined;

// @ts-expect-error VeterinarianIdをAppointmentIdとして使えません。
acceptAppointmentId(veterinarianId);

// @ts-expect-error AppointmentIdをVeterinarianIdとして使えません。
acceptVeterinarianId(appointmentId);

実行時テストでは、間違えた値を渡すとコンパイルエラーになることを確かめられません。@ts-expect-errorは次の行に型エラーが必要だと宣言します。後から2つの識別子が同じ型へ戻ると、不要なエラー抑制として型検査が失敗します。

演習

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

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

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

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

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

業務背景:
診察開始で AppointmentId と VeterinarianId を取り違えても、型検査が通った。

依頼:
診察開始で使う予約IDと担当獣医師IDを改善してください。同じUUID形式でも、用途を取り違えたコードはコンパイルを通らない状態を目指します。

取り違えてはいけない値: [言語化フェーズで書いた1文]

着手前に判断すること:
- AppointmentIdとVeterinarianIdをどの型として定義するか: [自分の判断を書く]
- 用途の区別を予約状態とstartExaminationへどう伝えるか: [自分の判断を書く]
- 取り違えをコンパイルエラーとしてどう残すか: [自分の判断を書く]

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

受け入れ条件:
- 予約IDと担当獣医師IDを、配布済みの識別子と同じ規約で別々の型にする。
- 予約の6状態とstartExaminationが、用途別の識別子を受け取るようにする。
- AppointmentIdとVeterinarianIdを入れ替えたコードがコンパイルできないことを、型テストで確かめる。

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

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

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

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

異なる用途は区別できますが、同じAppointmentId型を持つ別の予約を選ぶ誤りは型だけでは検出できない。

ステップごとの解答

予約IDと担当獣医師IDを、配布済みの識別子と同じ規約で別々の型にする。

examples/session-04/src/domain/appointment/appointmentId.ts

const schema = z.string().uuid().brand<"AppointmentId">();

export type AppointmentId = z.infer<typeof schema>;
export const AppointmentId = { schema, parse: schema.parse } as const;

examples/session-04/src/domain/appointment/veterinarianId.ts

const schema = z.string().uuid().brand<"VeterinarianId">();

export type VeterinarianId = z.infer<typeof schema>;
export const VeterinarianId = { schema, parse: schema.parse } as const;
予約の6状態とstartExaminationが、用途別の識別子を受け取るようにする。

examples/session-04/src/domain/appointment/appointment.ts

import type { AppointmentId } from "./appointmentId.js";
import type { VeterinarianId } from "./veterinarianId.js";
import type { ExamId } from "../examResult/index.js";
import type { OwnerId } from "../owner/index.js";
import type { PetId } from "../pet/index.js";

export type CancellationReason = string;

export type Scheduled = Readonly<{
  kind: "Scheduled";
  appointmentId: AppointmentId;
  petId: PetId;
  ownerId: OwnerId;
  scheduledAt: string;
  reason: string;

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

export const startExamination = (
  appointment: CheckedIn,
  veterinarianId: VeterinarianId,
  examinationStartedAt: string,
): InExamination =>
  ({
    ...appointment,
    kind: "InExamination",
    veterinarianId,
    examinationStartedAt,
  }) as const satisfies InExamination;
AppointmentIdとVeterinarianIdを入れ替えたコードがコンパイルできないことを、型テストで確かめる。

examples/session-04/src/domain/domain.test-types.ts

import { AppointmentId, VeterinarianId } from "./appointment/index.js";
import { clinicFixture } from "../../../fixtures/clinic.js";

const appointmentId = AppointmentId.parse(clinicFixture.appointmentId);
const veterinarianId = VeterinarianId.parse(clinicFixture.veterinarianId);
const acceptAppointmentId = (_id: AppointmentId): void => undefined;
const acceptVeterinarianId = (_id: VeterinarianId): void => undefined;

// @ts-expect-error VeterinarianIdをAppointmentIdとして使えません。
acceptAppointmentId(veterinarianId);

// @ts-expect-error AppointmentIdをVeterinarianIdとして使えません。
acceptVeterinarianId(appointmentId);

効果を確認する

pnpm exercise:03

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

演習を完了できないとき

現在の3ステップを順に開き、まずAppointmentIdとVeterinarianIdに別々のbrandを付けます。次に予約状態とstartExaminationへ反映し、取り違えがコンパイルエラーになることを型テストで確かめます。相互レビューの約束事はS2のリンク先を確認します。

レビューと持ち帰り

個人で確認する

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

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

残すもの: 取り違えてはいけない値、Agentへの依頼文、型検査では確認できず、テストまたは実行時に確認すること

業務へ持ち帰る

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

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

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

  1. `AppointmentId` と `VeterinarianId` を取り違えたコードは、型テストでコンパイルエラーになりますか。
  2. 予約の全状態で、`appointmentId` が `AppointmentId` になっていますか。
  3. `startExamination` は、担当獣医師を `VeterinarianId` として受け取っていますか。