WAN NYAN CLINIC

SESSION 04 · 30分

診察開始の入力を境界で検証する

HTTPから届く予約IDと担当獣医師IDを検査し、型付きの診察開始入力へ変換します。

今回つくるもの

外部の文字列を検査し、S3で区別した2種類のIDを持つ入力へ変換します。

実際に届いた入力

URLの予約IDはrouteがappointmentIdへ入れます。request bodyには、 獣医師選択欄の表示用データが誤って入りました。

フロントエンドが送信したHTTPリクエスト
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付きの入力へ変換する。

  1. HTTPのunknownrouteの外側では、入力の正しさを仮定しません。
  2. schemaでUUIDを検査2つのID schemaをz.objectでまとめてparseします。
  3. AppointmentId / VeterinarianId成功結果は、異なるbrandを持つTypeScriptの型として推論されます。実行時の文字列に印は付きません。
  4. 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行です。

  1. 診察開始の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として処理されます。

  2. 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を返します。

before: examples/session-04/src/boundary/startExaminationInput.ts
export const StartExaminationInput = {
  parse: (raw: any): Result<StartExaminationInput> =>
    ok({
      appointmentId: raw.appointmentId,
      veterinarianId: raw.veterinarianId,
    }),
} as const;
after: 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;
schemaResultが返すparserの入力型
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への依頼 → 検証

  1. 言語化(2分): 境界で拒否する入力を1文で書く。
  2. 依頼(8分): その1文と自分の判断をテンプレートへ入れ、Agent に依頼する。
  3. 検証(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のリンク先を確認します。

レビューと持ち帰り

個人で確認する

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

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

残すもの: 境界で拒否する入力、Agentへの依頼文、型検査では確認できず、テストまたは実行時に確認すること

業務へ持ち帰る

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

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

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

  1. 不正な予約IDまたは獣医師IDを含む入力は、`StartExaminationInput` になりませんか。
  2. `parse` は外部入力を `unknown` として受け取っていますか。
  3. 検証に成功した場合だけ、`AppointmentId` と `VeterinarianId` をユースケースへ渡せますか。