今回つくるもの
予約が見つからない場合と受付済みでない場合を、呼び出し側が処理できる業務エラーとして返します。
追加したい機能
診察開始で顧客や現場が失敗理由に応じて操作を選ぶ失敗を型付きの値として返し、Web側が受付の次の操作を選べるようにする。
素朴な実装の落とし穴
ユースケースは成功値だけを返す型のまま複数の Error をthrowします。Web側は予約なしの文言だけをcatchしているため、後から追加された状態不正を処理できず500にしています。
このセッションのゴール
予約なしと状態不正をkindを持つDiscriminated Unionにし、Resultで返す。andThenで失敗後の状態遷移とStore呼び出しを止める。状態遷移はドメイン関数を直接呼び、外部境界のResolverとStoreだけを依存として受け取る。Web側はmatchとswitchでResultに含めた失敗を網羅してnoticeへ変換する。
このセッションで守ること
- 顧客や現場が失敗理由に応じて操作を選ぶ必要がある失敗は、戻り値に現れる。
- 呼び出し側は文言ではなく失敗のkindで分岐する。
- 失敗経路では後続の処理を行わない。
今回の変更は examples/session-05/src 内の3ファイルに限定します。
コードを読み、失敗を再現する
成功値しか現れない関数シグネチャ、複数箇所のthrow、例外メッセージに依存したcatchを読みます。受付前の予約だけ500になる処理漏れを、演習テストで再現します。
編集する範囲は examples/session-05/src、変更するファイル数は最大
3ファイル・約80行です。
-
起こりうる業務エラーが戻り値の型に現れない
src/useCase/errors.ts:4-23 export const ensureAppointmentFound = ( appointment: Appointment | undefined, appointmentId: AppointmentId, ): Appointment => { if (appointment === undefined) { throw new Error(`Appointment ${appointmentId} was not found`); } return appointment; }; export const ensureCheckedIn = ( appointment: Appointment, ): CheckedIn => { if (appointment.kind !== "CheckedIn") { throw new Error(`Appointment state was ${appointment.kind}`); } return appointment; };guard は成功値だけを返す型ですが、予約なしと状態不正では Error をthrowします。 呼び出し側は実装を読まないと、どの例外を処理すべきか判断できません。
-
Web側が予約なしだけを文言でcatchしている
src/web/routes.ts:76-94 appointmentId: context.req.param("appointmentId"), veterinarianId: raw.veterinarianId, })._unsafeUnwrap(); try { startExamination({ resolver: store, store, })({ ...input, examinationStartedAt: "2026-08-30T06:30:00.000Z", }); return context.redirect("/", 303); } catch (error) { if (error instanceof Error && error.message.includes("was not found")) { return context.redirect("/?notice=not-found", 303); } throw error; } });例外メッセージにwas not foundが含まれる場合だけnoticeへ変換し、それ以外は500になります。 例外の種類を追加しても呼び出し側に型エラーが出ず、今回のような処理漏れが残ります。
修正前の失敗を確認する
修正前は、次の4件に対応する演習テストが失敗します。
- 受付済みでない予約を、例外として処理している。
- 予約が見つからない場合も、例外として処理している。
- ユースケースが失敗理由をResultで運ばず、型から後続処理の条件を読めない。
- 状態不正の例外をWeb側が処理せず、500エラーにする。
失敗を確認する
pnpm exercise:05 期待結果: 8件の演習テストが失敗します。
ブラウザ内の変更はローカルへ反映されません。
例外の種類が型に現れず、Web側にcatch漏れがある開始 snapshot を確認します。
exercises/result-errors.test.tsimport { err } from "neverthrow";import { describe, expect, expectTypeOf, it } from "vitest";import { createApp } from "../src/app.js";import type { Appointment, CheckedIn, Scheduled,} from "../src/domain/appointment/index.js";import { AppointmentId } from "../src/domain/appointment/index.js";import { OwnerId } from "../src/domain/owner/index.js";import { PetId } from "../src/domain/pet/index.js";import { VeterinarianId } from "../src/domain/appointment/index.js";import type { Dependencies } from "../src/useCase/dependencies.js";import { ensureAppointmentFound, ensureCheckedIn,} from "../src/useCase/errors.js";import type { StartExaminationError } from "../src/useCase/errors.js"; // 要件: 予約なしと状態不正を、kindで区別できる診察開始エラーとして定義してください。import { startExamination } from "../src/useCase/startExamination.js";import type { startExaminationNoticeCodes } from "../src/web/routes.js"; // 要件: 診察開始エラーのkindをキーにした通知対応表を公開してください。import { clinicFixture } from "../../fixtures/clinic.js";const appointmentId = AppointmentId.parse(clinicFixture.appointmentId);const veterinarianId = VeterinarianId.parse(clinicFixture.veterinarianId);const scheduled = { kind: "Scheduled", appointmentId, petId: PetId.parse(clinicFixture.petId), ownerId: OwnerId.parse(clinicFixture.ownerId), scheduledAt: clinicFixture.scheduledAt, reason: "skin check",} as const satisfies Scheduled;const checkedIn = { ...scheduled, kind: "CheckedIn", checkedInAt: clinicFixture.checkedInAt,} as const satisfies CheckedIn;const input = { appointmentId, veterinarianId, examinationStartedAt: "2026-08-30T06:30:00.000Z",} as const;type AppointmentUnavailable = Readonly<{ kind: "AppointmentUnavailable";}>;type ErrorWithNewVariant = StartExaminationError | AppointmentUnavailable;describe("Step 1: InvalidAppointmentState を値として返す", () => { it("CheckedIn でない予約でも例外を投げない", () => { try { const result = ensureCheckedIn(scheduled); expect(result).toEqual(err({ kind: "InvalidAppointmentState", actual: "Scheduled", })); } catch { throw new Error("要件未達: 来院済みでない予約は状態不正として返してください。"); } });});describe("Step 2: AppointmentNotFound を値として返す", () => { it("予約が見つからなくても例外を投げない", () => { try { const result = ensureAppointmentFound(undefined, appointmentId); expect(result).toEqual(err({ kind: "AppointmentNotFound", appointmentId })); } catch { throw new Error("要件未達: 見つからない予約は予約なしとして返してください。"); } });});describe("Step 3: andThen pipeline が失敗理由を運ぶ", () => { it("予約なしを保持し、保存しない", () => { let saveCalls = 0; const deps = createDependencies(undefined, { onSave: () => { saveCalls += 1; }, }); try { const result = startExamination(deps)(input); expect(result).toEqual(err({ kind: "AppointmentNotFound", appointmentId })); expect(saveCalls).toBe(0); } catch { throw new Error("要件未達: 予約なしの理由を保持し、保存を実行しないでください。"); } }); it("状態不正の後も保存しない", () => { let saveCalls = 0; const deps = createDependencies(scheduled, { onSave: () => { saveCalls += 1; }, }); try { const result = startExamination(deps)(input); expect(result).toEqual(err({ kind: "InvalidAppointmentState", actual: "Scheduled", })); expect(saveCalls).toBe(0); } catch { throw new Error("要件未達: 状態不正の理由を保持し、保存を実行しないでください。"); } }); it("保存障害を業務エラーへ変換せず例外として伝える", () => { const saveFailure = new Error("database unavailable"); const deps = createDependencies(checkedIn, { onSave: () => { throw saveFailure; }, }); expect(() => startExamination(deps)(input)).toThrow(saveFailure); });});describe("Step 4: 呼び出し側が業務エラーを漏れなく処理する", () => { it("状態不正を専用noticeへ変換する", async () => { const response = await post( createApp(), `/appointments/${clinicFixture.appointmentId}/start-examination`, ); if (response.headers.get("location") !== "/?notice=invalid-state") { throw new Error("要件未達: 状態不正を専用のお知らせへ変換してください。"); } }); it("予約なしを専用noticeへ変換する", async () => { const response = await post( createApp(), "/appointments/99999999-9999-4999-8999-999999999999/start-examination", ); expect(response.headers.get("location")).toBe("/?notice=not-found"); }); it("診察開始エラーのkindを通知対応表で漏れなく扱う", () => { expectTypeOf<keyof typeof startExaminationNoticeCodes>() .toEqualTypeOf<StartExaminationError["kind"]>(); // 要件: 通知対応表は診察開始エラーのkindを過不足なくキーにしてください。 }); it("業務エラーを追加すると通知対応表の不足を型で検出する", () => { expectTypeOf<keyof typeof startExaminationNoticeCodes>() .not.toEqualTypeOf<ErrorWithNewVariant["kind"]>(); // 要件: 業務エラーを追加したら通知対応表にもキーを追加してください。 });});const createDependencies = ( resolved: Appointment | undefined, observer: Readonly<{ onSave?: () => void; }> = {},): Dependencies => ({ resolver: { resolveById: () => resolved }, store: { save: () => { observer.onSave?.(); }, },});const post = async ( app: ReturnType<typeof createApp>, path: string,): Promise<Response> => app.request(path, { method: "POST", headers: { Accept: "application/json", "X-Inertia": "true", "X-Inertia-Version": "1", }, });事前知識(8分)
まずResultの意味とResultに含める失敗の基準を整理し、その後でこの回に使う操作を確認します。配布コードの書き方を before、次のスナップショットの書き方を after として並べています。
Result は成功と失敗を値で表す
このプロジェクトでは neverthrow の Result を使います。Result<T, E> の T は成功値、E は呼び出し側が扱う業務上の失敗です。ok(value) は成功側の Ok、err(error) は失敗側の Err を作り、どちらも Result<T, E> として扱えます。
Result に含める失敗は、先に呼び出し側の対応から選びます。失敗理由に応じて呼び出し側が次の操作を選べる業務エラーを E として返します。
たとえば、予約が見つからなければ予約を探し直し、まだ受付されていなければ受付を先に行います。呼び出し側が理由ごとに異なる対応を選ぶ必要があるため、これらは Err で返します。
一方、呼び出し側に理由別の対応がなく、要求された処理を完了できない異常はシステムエラーとして扱います。このような異常はResultに変換せず、例外として外側へ伝えます。
たとえば、データベースへの保存中に接続が切れ、保存が完了したか確認できない場合です。データベースへの保存が完了したか確認できないままキューへのイベント追加や外部サービスへの通知を行うと、データベースには保存されていないのに、イベントや通知だけが送られる可能性があります。そのため、イベント追加や通知は実行せず、保存時の例外をそのまま外側へ伝えます。
外側の例外境界では、例外をログへ記録し、必要なロールバックや接続の解放を行ったうえで、500などのエラー応答へ変換します。
const ensureAppointmentFound = (
appointment: Appointment | undefined,
appointmentId: AppointmentId,
): Appointment => {
if (appointment === undefined) {
throw new Error(`Appointment ${appointmentId} was not found`);
}
return appointment;
}; import { err, ok, type Result } from "neverthrow";
const ensureAppointmentFound = (
appointment: Appointment | undefined,
appointmentId: AppointmentId,
): Result<Appointment, AppointmentNotFound> =>
appointment === undefined
? err({ kind: "AppointmentNotFound", appointmentId })
: ok(appointment); 例外では、関数の型から失敗理由を読み取れません。Result の E に業務エラーを置くと、呼び出し側は実行前から起こりうる失敗を把握し、表示や次の操作を選べます。err は例外を投げず、失敗理由を値として返します。
andThen と map は成功した経路だけを進める
andThen は Ok のときだけ次の Result を返す処理へ進み、map は Ok の成功値だけを変換します。Err になった後のコールバックは呼ばれません。状態遷移は外部依存ではなくドメインの純粋関数なので、ユースケースから直接呼びます。
import {
startExamination as transitionToInExamination,
} from "../domain/appointment/index.js";
const startExamination =
(deps: Dependencies) =>
(input: StartExaminationInput): InExamination => {
const found = ensureAppointmentFound(
deps.resolver.resolveById(input.appointmentId),
input.appointmentId,
);
const checkedIn = ensureCheckedIn(found);
const next = transitionToInExamination(
checkedIn,
input.veterinarianId,
input.examinationStartedAt,
);
deps.store.save(next);
return next;
}; import type { Result } from "neverthrow";
import type { Appointment } from "../domain/appointment/index.js";
import {
startExamination as transitionToInExamination,
} from "../domain/appointment/index.js";
import type { AppointmentId } from "../domain/appointment/index.js";
import type { OwnerId } from "../domain/owner/index.js";
import type { PetId } from "../domain/pet/index.js";
import type { VeterinarianId } from "../domain/appointment/index.js";
type InExamination = Readonly<{
kind: "InExamination";
appointmentId: AppointmentId;
petId: PetId;
ownerId: OwnerId;
scheduledAt: string;
reason: string;
checkedInAt: string;
veterinarianId: VeterinarianId;
examinationStartedAt: string;
}>;
type AppointmentNotFound = Readonly<{
kind: "AppointmentNotFound";
appointmentId: AppointmentId;
}>;
type InvalidAppointmentState = Readonly<{
kind: "InvalidAppointmentState";
actual: Appointment["kind"];
}>;
type StartExaminationError =
| AppointmentNotFound
| InvalidAppointmentState;
const startExamination =
(deps: Dependencies) =>
(input: StartExaminationInput): Result<
InExamination,
StartExaminationError
> =>
ensureAppointmentFound(
deps.resolver.resolveById(input.appointmentId),
input.appointmentId,
)
.andThen(ensureCheckedIn)
.map((appointment) =>
transitionToInExamination(
appointment,
input.veterinarianId,
input.examinationStartedAt,
),
)
.map((appointment) => {
deps.store.save(appointment);
return appointment;
}); before は実行時には例外で処理が止まりますが、どの行が何をthrowするかは型に現れません。after は成功時に InExamination のどの情報が得られるかと、予約なし・状態不正のどちらで失敗するかを定義から確認できます。Err になった時点で後続の andThen と map は呼ばれません。resolver と store は外部境界として deps から受け取りますが、状態遷移はドメイン知識なので直接呼びます。store.save が投げる保存障害は、呼び出し側が理由別の対応を選ぶ業務エラーではなく、要求された保存処理を完了できないシステムエラーとして例外を伝播させます。
match と switch で呼び出し側の処理漏れを型エラーにする
Result.match は成功時と失敗時の処理を分けます。失敗側は StartExaminationError 全体を受け取り、error.kind の switch で各 notice へ変換します。
try {
startExamination(dependencies)(input);
return context.redirect("/", 303);
} catch (error) {
if (
error instanceof Error &&
error.message.includes("was not found")
) {
return context.redirect("/?notice=not-found", 303);
}
// 状態不正の例外はここへ到達し、500になる。
throw error;
} const assertNever = (error: never): never => {
throw new Error(`Unhandled error: ${JSON.stringify(error)}`);
};
const toNoticeCode = (error: StartExaminationError) => {
switch (error.kind) {
case "AppointmentNotFound":
return "not-found";
case "InvalidAppointmentState":
return "invalid-state";
default:
return assertNever(error);
}
};
return result.match(
() => context.redirect("/", 303),
(error) =>
context.redirect(
`/?notice=${toNoticeCode(error)}`,
303,
),
); type AppointmentConflict = Readonly<{
kind: "AppointmentConflict";
}>;
type StartExaminationError =
| AppointmentNotFound
| InvalidAppointmentState
| AppointmentConflict;
const toNoticeCode = (error: StartExaminationError): string => {
switch (error.kind) {
case "AppointmentNotFound":
return "not-found";
case "InvalidAppointmentState":
return "invalid-state";
default:
// errorはAppointmentConflictなので、neverへ渡せない。
return assertNever(error);
}
}; before では InvalidAppointmentState が追加されても catch に型エラーが出ないため、受付前の予約だけ500になりました。after は match の失敗側で union 全体を受け取り、default 節から never 専用の assertNever を呼びます。エラーの種類を追加して case を足し忘れると新しい型が default 節に残り、コンパイルエラーになります。Result に含めていないシステムエラーは、この switch では捕捉せず外側の例外境界へ伝えます。
演習
進め方: 言語化 → Agentへの依頼 → 検証
- 言語化(2分): 失敗後に実行してはいけない処理を1文で書く。
- 依頼(5分): その1文と自分の判断をテンプレートへ入れ、Agent に依頼する。
- 検証(3分): 型、テスト、差分の範囲を確認する。
プロンプトのテンプレート
角括弧の中は、事故と配布コードを読んだ自分の判断で埋めます。技法名や完成形を先に指定する必要はありません。
次の変更を実装してください。
業務背景:
診察開始で状態不正の例外が追加されたが、Web側にcatch分岐を足し忘れ、古い画面から送られた受付前の予約だけ500になった。
依頼:
診察開始で起こりうる業務上の失敗を、呼び出し側が判断できる形に改善してください。表示文言には依存せず、失敗後の処理を続けない設計にしてください。
失敗後に実行してはいけない処理: [言語化フェーズで書いた1文]
着手前に判断すること:
- どの失敗に対して顧客や現場が別の操作を選ぶか: [自分の判断を書く]
- 呼び出し側が分岐に使う安定した情報は何か: [自分の判断を書く]
- 失敗後に実行してはいけない処理は何か: [自分の判断を書く]
- 要求された処理を完了できず、Resultへ変換せず外側へ伝えるシステムエラーは何か: [自分の判断を書く]
まず配布コードと失敗しているテストを読み、上の判断と変更方針を短く説明してください。説明のあとで実装に進んでください。設計手段は既存コードに合うものを選び、選んだ理由も示してください。
受け入れ条件:
- 受付済みでない状態を型付きの失敗として返す。
- 予約が見つからない失敗を型付きの値として返す。
- 失敗理由をandThenで運び、失敗後の遷移と保存を実行しない。
- Web側で業務エラーのkindを網羅し、それぞれ専用noticeへ変換する。
変更範囲:
- examples/session-05/src/ のみ
- 最大 3 ファイル・約 80 行
- 範囲外の変更が必要に見えた場合は、変更せず理由を報告する
- 型エラーを `as` によるキャストで回避しない
検証:
- pnpm exercise:05 を実行し、すべての assertion が成功すること
完了時の報告:
- 変更したファイルと判断理由
- 検証コマンドの結果
- 型だけでは守れず、テストまたはレビューに残した点 この演習で解決しないこと
どの失敗を業務エラーとして画面のnoticeへ変換するかは、人の判断とレビューが必要になる。呼び出し側に理由別の対応がなく、要求された処理を完了できないシステムエラーはResultへ変換せず、外側の例外境界へ伝える。
ステップごとの解答
受付済みでない状態を型付きの失敗として返す。
examples/session-06/src/useCase/errors.ts
import { err, ok, type Result } from "neverthrow";
import type { Appointment, CheckedIn } from "../domain/appointment/index.js";
import type { AppointmentId } from "../domain/appointment/index.js";
export type AppointmentNotFound = Readonly<{
kind: "AppointmentNotFound";
appointmentId: AppointmentId;
}>;
export type InvalidAppointmentState = Readonly<{
kind: "InvalidAppointmentState";
actual: Appointment["kind"];
}>;
export type StartExaminationError = AppointmentNotFound | InvalidAppointmentState;
export const ensureAppointmentFound = (
appointment: Appointment | undefined,
appointmentId: AppointmentId,
): Result<Appointment, AppointmentNotFound> =>
appointment === undefined
? err({ kind: "AppointmentNotFound", appointmentId })
: ok(appointment);
export const ensureCheckedIn = (
appointment: Appointment,
): Result<CheckedIn, InvalidAppointmentState> =>
appointment.kind === "CheckedIn"
? ok(appointment)
: err({ kind: "InvalidAppointmentState", actual: appointment.kind }); 予約が見つからない失敗を型付きの値として返す。
examples/session-06/src/useCase/errors.ts
import { err, ok, type Result } from "neverthrow";
import type { Appointment, CheckedIn } from "../domain/appointment/index.js";
import type { AppointmentId } from "../domain/appointment/index.js";
export type AppointmentNotFound = Readonly<{
kind: "AppointmentNotFound";
appointmentId: AppointmentId;
}>;
export type InvalidAppointmentState = Readonly<{
kind: "InvalidAppointmentState";
actual: Appointment["kind"];
}>;
export type StartExaminationError = AppointmentNotFound | InvalidAppointmentState;
export const ensureAppointmentFound = (
appointment: Appointment | undefined,
appointmentId: AppointmentId,
): Result<Appointment, AppointmentNotFound> =>
appointment === undefined
? err({ kind: "AppointmentNotFound", appointmentId })
: ok(appointment);
export const ensureCheckedIn = (
appointment: Appointment,
): Result<CheckedIn, InvalidAppointmentState> =>
appointment.kind === "CheckedIn"
? ok(appointment)
: err({ kind: "InvalidAppointmentState", actual: appointment.kind }); 失敗理由をandThenで運び、失敗後の遷移と保存を実行しない。
examples/session-06/src/useCase/startExamination.ts
import type { Result } from "neverthrow";
import type { InExamination } from "../domain/appointment/index.js";
import { startExamination as transitionToInExamination } from "../domain/appointment/index.js";
import type { AppointmentId } from "../domain/appointment/index.js";
import type { VeterinarianId } from "../domain/appointment/index.js";
import type { Dependencies } from "./dependencies.js";
import {
ensureAppointmentFound,
ensureCheckedIn,
type StartExaminationError,
} from "./errors.js";
export type StartExaminationInput = Readonly<{
appointmentId: AppointmentId;
veterinarianId: VeterinarianId;
examinationStartedAt: string;
}>;
export const startExamination =
(deps: Dependencies) =>
(input: StartExaminationInput): Result<InExamination, StartExaminationError> =>
ensureAppointmentFound(
deps.resolver.resolveById(input.appointmentId),
input.appointmentId,
)
.andThen(ensureCheckedIn)
.map((appointment) =>
transitionToInExamination(
appointment,
input.veterinarianId,
input.examinationStartedAt,
),
)
.map((appointment) => {
deps.store.save(appointment);
return appointment;
}); Web側で業務エラーのkindを網羅し、それぞれ専用noticeへ変換する。
examples/session-06/src/web/routes.ts
import type { StartExaminationError } from "../useCase/errors.js";
type StartExaminationNoticeCode = "not-found" | "invalid-state";
export const startExaminationNoticeCodes: Readonly<
Record<StartExaminationError["kind"], StartExaminationNoticeCode>
> = {
AppointmentNotFound: "not-found",
InvalidAppointmentState: "invalid-state",
};
const assertNever = (error: never): never => {
throw new Error(`Unhandled start examination error: ${JSON.stringify(error)}`);
};
const toStartExaminationNoticeCode = (
error: StartExaminationError,
): StartExaminationNoticeCode => {
switch (error.kind) {
case "AppointmentNotFound":
return startExaminationNoticeCodes.AppointmentNotFound;
case "InvalidAppointmentState":
return startExaminationNoticeCodes.InvalidAppointmentState;
default:
return assertNever(error);
}
}; 効果を確認する
pnpm exercise:05 期待結果: 演習テストがすべて成功します。
演習を完了できないとき
2つのguardと startExamination は解答例を反映します。最後にWebルートをmatchとswitchへ置き換え、2種類のnoticeを確認します。
レビューと持ち帰り
個人で確認する
- `as` によるキャストが入っていないか全文検索して確認する。
- `git diff --stat -- examples/session-05` で今回の snapshot だけを確認する。`git status --short` で想定外の path がないか確認する。
- 型検査では確認できないことを、テストまたは実行時に確認して記録する。
前のセッションの未commit差分は残して構いません。reset、stash、commit は不要です。
残すもの: 失敗後に実行してはいけない処理、Agentへの依頼文、型検査では確認できず、テストまたは実行時に確認すること
業務へ持ち帰る
自分の業務コードで、今回と同種の問題が起きうる箇所はどこですか。
班内相互レビュー(8分・1〜2名)
- 予約なしと状態不正は、異なる `kind` を持つ `Err` になっていますか。
- `andThen` は、失敗後の状態遷移と保存を実行しない構造になっていますか。
- Web側は業務エラーを `kind` で網羅し、未対応の種類を型エラーにできますか。