今回つくるもの
外部の文字列を検査し、S3で区別した2種類のIDを持つ入力へ変換します。
実際に届いた入力
URLの予約IDはrouteがappointmentIdへ入れます。request bodyには、
獣医師選択欄の表示用データが誤って入りました。
POST /appointments/11111111-1111-4111-8111-111111111111/start-examination
Content-Type: application/json
{
"veterinarianId": "night-shift"
} 現在のparse
Ok({
appointmentId: "11111111-1111-4111-8111-111111111111",
veterinarianId: "night-shift",
}) 型注釈だけを信頼し、不正な値から型付き入力を作ります。
修正後のparse
Err()
UUIDではないため、StartExaminationInputを作らない結果にします。
追加したい機能
HTTPから届く予約IDと担当獣医師IDを検証し、正しい値だけをstartExaminationへ渡す。
素朴な実装の落とし穴
raw: anyから値をコピーして必ずOkを返すため、night-shiftでもStartExaminationInputとして扱えます。
このセッションのゴール
AppointmentId.schemaとVeterinarianId.schemaをobject schemaへ組み合わせ、unknownの入力全体をparseしてbrand付きの入力へ変換する。
- HTTPのunknownrouteの外側では、入力の正しさを仮定しません。
- schemaでUUIDを検査2つのID schemaをz.objectでまとめてparseします。
- AppointmentId / VeterinarianId成功結果は、異なるbrandを持つTypeScriptの型として推論されます。実行時の文字列に印は付きません。
- StartExaminationInput正しい2つのIDがそろった場合だけ構築します。
このセッションで守ること
- 外部入力はunknownとして受け取り、境界で検証してから型付き入力になる。
- 予約IDと担当獣医師IDの両方が正しい場合だけ、startExaminationへ渡す。
今回の変更は examples/session-04/src/boundary 内の1モジュールに限定します。
コードを読み、失敗を再現する
routes.tsからparseへ渡すraw objectと、startExaminationInput.tsが必ずOkを返す実装を順に読み、night-shiftが型付き入力になる理由を確認します。
編集する範囲は examples/session-04/src/boundary、変更するファイル数は最大
1ファイル・約18行です。
-
診察開始のHTTP入力はrouteで組み立てている
src/web/routes.ts:109-115 app.post("/appointments/:appointmentId/start-examination", async (context) => { const raw = await context.req.json<Readonly<{ veterinarianId?: unknown }>>(); const input = StartExaminationInput.parse({ appointmentId: context.req.param("appointmentId"), veterinarianId: raw.veterinarianId, })._unsafeUnwrap(); const current = appointmentOrThrow(repository, input.appointmentId);path parameterの予約IDとrequest bodyのveterinarianIdを、raw objectへまとめてStartExaminationInput.parseへ渡しています。 bodyへnight-shiftが入っても、境界のparserが検査しなければStartExaminationInputとして処理されます。
-
HTTP入力をanyのまま診察開始の入力へ入れている
src/boundary/startExaminationInput.ts:1-15 import type { AppointmentId } from "../domain/appointment/index.js"; import type { VeterinarianId } from "../domain/appointment/index.js"; import { ok, type Result } from "../shared/schemaResult.js"; export type StartExaminationInput = Readonly<{ appointmentId: AppointmentId; veterinarianId: VeterinarianId; }>; export const StartExaminationInput = { parse: (raw: any): Result<StartExaminationInput> => ok({ appointmentId: raw.appointmentId, veterinarianId: raw.veterinarianId, }),raw: anyから2項目をコピーし、検査せずに必ずokを返しています。 TypeScriptは返り値の型注釈を信頼するため、night-shiftもVeterinarianIdとして後続処理へ渡ります。
修正前の失敗を確認する
修正前は、次の2件に対応する演習テストが失敗します。
- 不正な予約IDでも、診察開始の入力として扱える。
- 不正な担当獣医師IDでも、診察開始の入力として扱える。
失敗を確認する
pnpm exercise:04 期待結果: 2件の演習テストが失敗します。
ブラウザ内の変更はローカルへ反映されません。
HTTP入力を診察開始の型付き入力へ変換する境界を確認します。
exercises/start-examination-input.test.tsimport { describe, expect, expectTypeOf, it } from "vitest";import { clinicFixture } from "../../fixtures/clinic.js";import { StartExaminationInput, type StartExaminationInput as StartExaminationInputValue,} from "../src/boundary/startExaminationInput.js";describe("Step 1: HTTP入力を診察開始の入力へ変換する", () => { it("正しい予約IDと獣医師IDを型付き入力へ変換する", () => { const result = StartExaminationInput.parse({ appointmentId: clinicFixture.appointmentId, veterinarianId: clinicFixture.veterinarianId, }); expect(result.isOk()).toBe(true); expectTypeOf(result._unsafeUnwrap()).toMatchTypeOf<StartExaminationInputValue>(); });});describe("Step 2: 不正なIDを境界で拒否する", () => { it("不正な予約IDをerrにする", () => { expect( StartExaminationInput.parse({ appointmentId: "invalid", veterinarianId: clinicFixture.veterinarianId, }).isErr(), ).toBe(true); }); it("不正な獣医師IDをerrにする", () => { expect( StartExaminationInput.parse({ appointmentId: clinicFixture.appointmentId, veterinarianId: "night-shift", }).isErr(), ).toBe(true); });});事前知識(7分)
この回でつなげる3つの考え方を確認します。
- Branded Type
- 同じUUID形式でもAppointmentIdとVeterinarianIdを型で区別します。この区別はTypeScriptの型検査で使われ、実行時の文字列は変えません。
- Parse, don't validate
- 真偽値だけを返してrawを使い続けず、parseの成功結果として型付きの値を返します。
- Always-Valid Domain Model
- 境界のparseに成功した値だけをStartExaminationInputにし、schemaが表現する制約を満たさない入力を通常の処理へ持ち込みません。
次に、配布コードの書き方をbefore、修正後の書き方をafterとして見比べます。
schemaでunknownからbrand付き入力を作る
AppointmentId.schemaとVeterinarianId.schemaをobject schemaへ組み合わせ、外から届くunknownを一度だけparseします。成功時はbrand付きのStartExaminationInput、失敗時はErrを返します。
examples/session-04/src/boundary/startExaminationInput.ts export const StartExaminationInput = {
parse: (raw: any): Result<StartExaminationInput> =>
ok({
appointmentId: raw.appointmentId,
veterinarianId: raw.veterinarianId,
}),
} as const; examples/session-05/src/boundary/startExaminationInput.ts const schema = z.object({
appointmentId: AppointmentId.schema,
veterinarianId: VeterinarianId.schema,
}).readonly();
export type StartExaminationInput = z.infer<typeof schema>;
export const StartExaminationInput = {
schema,
parse: schemaResult(schema),
} as const; const parse: (raw: unknown) => Result<StartExaminationInput> =
schemaResult(schema); beforeはrawをanyで受け、返り値の型注釈だけで不正な値をStartExaminationInputにしています。afterはz.objectで2つのschemaを合成し、z.inferで成功後の型をschemaから導出します。schemaResultはunknownを受け取り、両方のparseに成功した場合だけ型付き入力を返します。Resultを使った後続処理の分岐と合成はS5で扱います。
演習
進め方: 言語化 → Agentへの依頼 → 検証
- 言語化(2分): 境界で拒否する入力を1文で書く。
- 依頼(8分): その1文と自分の判断をテンプレートへ入れ、Agent に依頼する。
- 検証(2分): 型、テスト、差分の範囲を確認する。
プロンプトのテンプレート
角括弧の中は、事故と配布コードを読んだ自分の判断で埋めます。技法名や完成形を先に指定する必要はありません。
次の変更を実装してください。
業務背景:
HTTPから届いた不正なIDを検査せず、診察開始の入力として扱った。
依頼:
HTTPから届く診察開始の入力を改善してください。不正な予約IDや担当獣医師IDが、型付き入力としてユースケースへ渡らない状態を目指します。
境界で拒否する入力: [言語化フェーズで書いた1文]
着手前に判断すること:
- 外部入力をunknownとして受け取る場所はどこか: [自分の判断を書く]
- S3で作った2種類のschemaをどう組み合わせるか: [自分の判断を書く]
まず配布コードと失敗しているテストを読み、上の判断と変更方針を短く説明してください。説明のあとで実装に進んでください。設計手段は既存コードに合うものを選び、選んだ理由も示してください。
受け入れ条件:
- 不正な予約IDを含むHTTP入力は、StartExaminationInputにならないようにする。
- 不正な担当獣医師IDを含むHTTP入力は、StartExaminationInputにならないようにする。
変更範囲:
- examples/session-04/src/boundary/ のみ
- 最大 1 ファイル・約 18 行
- 範囲外の変更が必要に見えた場合は、変更せず理由を報告する
- 型エラーを `as` によるキャストで回避しない
検証:
- pnpm exercise:04 を実行し、すべての assertion が成功すること
完了時の報告:
- 変更したファイルと判断理由
- 検証コマンドの結果
- 型だけでは守れず、テストまたはレビューに残した点 この演習で解決しないこと
schemaはUUIDの形式を検査しますが、そのUUIDが実在する予約や獣医師を指すかは判断しません。対象の取得と状態確認はS5で扱います。
ステップごとの解答
不正な予約IDを含むHTTP入力は、StartExaminationInputにならないようにする。
examples/session-05/src/boundary/startExaminationInput.ts
const schema = z.object({
appointmentId: AppointmentId.schema,
veterinarianId: VeterinarianId.schema,
}).readonly();
export type StartExaminationInput = z.infer<typeof schema>;
export const StartExaminationInput = {
schema,
parse: schemaResult(schema),
} as const;
不正な担当獣医師IDを含むHTTP入力は、StartExaminationInputにならないようにする。
examples/session-05/src/boundary/startExaminationInput.ts
const schema = z.object({
appointmentId: AppointmentId.schema,
veterinarianId: VeterinarianId.schema,
}).readonly();
export type StartExaminationInput = z.infer<typeof schema>;
export const StartExaminationInput = {
schema,
parse: schemaResult(schema),
} as const;
効果を確認する
pnpm exercise:04 期待結果: 演習テストがすべて成功します。
演習を完了できないとき
startExaminationInput.tsの手書き型をz.inferへ変え、AppointmentId.schemaとVeterinarianId.schemaを持つobject schemaを作ります。parseにはschemaResultを使います。相互レビューの約束事はS2のリンク先を確認します。
レビューと持ち帰り
個人で確認する
- `as` によるキャストが入っていないか全文検索して確認する。
- `git diff --stat -- examples/session-04` で今回の snapshot だけを確認する。`git status --short` で想定外の path がないか確認する。
- 型検査では確認できないことを、テストまたは実行時に確認して記録する。
前のセッションの未commit差分は残して構いません。reset、stash、commit は不要です。
残すもの: 境界で拒否する入力、Agentへの依頼文、型検査では確認できず、テストまたは実行時に確認すること
業務へ持ち帰る
自分の業務コードで、今回と同種の問題が起きうる箇所はどこですか。
班内相互レビュー(7分・1〜2名)
- 不正な予約IDまたは獣医師IDを含む入力は、`StartExaminationInput` になりませんか。
- `parse` は外部入力を `unknown` として受け取っていますか。
- 検証に成功した場合だけ、`AppointmentId` と `VeterinarianId` をユースケースへ渡せますか。