今回つくるもの
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行です。
-
予約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行があっても型検査は成功します。
-
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なのでコンパイルは通ります。
-
予約状態と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を入れ替えても検出できません。
取り違えたまま型検査する
acceptAppointmentId と acceptVeterinarianId の呼び出しは、渡す値が逆です。この状態で型検査を実行します。
pnpm --filter @fp-with-ts/clinic-session-03 typecheck 期待結果: 取り違えた2行が残っていても、型検査は成功します。
型検査が成功したこと自体が失敗の再現です。現在の AppointmentId と VeterinarianId は、どちらも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 を付けると、互いに代入できない型ができます。
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; 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 を利用する形に変更します。
examples/session-03/src/domain/appointment/appointment.ts、examples/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 => examples/session-04/src/domain/appointment/appointment.ts、examples/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 は「不要なエラー抑制を行なっている」ことになり、逆にコンパイルエラーとなります。
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); 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への依頼 → 検証
- 言語化(2分): 取り違えてはいけない値を1文で書く。
- 依頼(9分): その1文と自分の判断をテンプレートへ入れ、Agent に依頼する。
- 検証(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のリンク先を確認します。
レビューと持ち帰り
個人で確認する
- `as` によるキャストが入っていないか全文検索して確認する。
- `git diff --stat -- examples/session-03` で今回の snapshot だけを確認する。`git status --short` で想定外の path がないか確認する。
- 型検査では確認できないことを、テストまたは実行時に確認して記録する。
前のセッションの未commit差分は残して構いません。reset、stash、commit は不要です。
残すもの: 取り違えてはいけない値、Agentへの依頼文、型検査では確認できず、テストまたは実行時に確認すること
業務へ持ち帰る
自分の業務コードで、今回と同種の問題が起きうる箇所はどこですか。
班内相互レビュー(7分・1〜2名)
- `AppointmentId` と `VeterinarianId` を取り違えたコードは、型テストでコンパイルエラーになりますか。
- 予約の全状態で、`appointmentId` が `AppointmentId` になっていますか。
- `startExamination` は、担当獣医師を `VeterinarianId` として受け取っていますか。