第19章:LSP実戦(型で守る置換可能性)🧠🛡️✨
(2026-01-09時点の前提メモ💡:TypeScriptは5.9系が最新として案内されていて、Vitestは4.0が出ています🧪✨ Node.jsは24系がLTS入りしています🚀) (TypeScript)

この章のゴール🎯💖
この章が終わるころには…
- 「差し替え可能(置換可能)」って、実務でどう守るの? が言えるようになる😊
- TypeScriptの「型」で、“置換できない実装”を入り口で弾けるようになる🧱✨
- さらにテストで、“型だけじゃ守れない約束”を自動チェックできるようになる✅🧪
まず大事なこと:LSPは「継承の話」だけじゃないよ😳🧩
TypeScriptは 構造的型付け(形が同じならOK) だから…
implementsしてなくてもextendsしてなくても
「たまたま同じ形」なら差し替えできちゃうのね😇 だからこそ、LSPは「継承設計の作法」だけじゃなくて、“差し替え口(interface)を作る時の契約(約束)”の作法として超重要になるよ🫶✨
LSPの“実戦”で見るべきポイント👀⚔️
差し替えで壊れるのって、だいたいこの4つ😵💫
- 入力の前提(事前条件)を勝手にキツくする
- 例:インターフェースは「0以上の金額」を想定してたのに、実装Bだけ「100円以上しか無理」みたいにする💥
- 出力の約束(事後条件)を弱くする
- 例:成功ならレシートが必ず返るはずなのに、実装Bだけ
nullが返る😇
- 例外やエラーの出し方が変わる
- 例:他は
Resultで返すのに、実装Bだけ throw する🔥
- 副作用が増える(勝手にログ保存・DB更新…)
- 置換したら挙動が変わって、思わぬお金が飛ぶやつ😱💸
この章は、1〜3を「型+テスト」でガッチリ守るのがメインだよ🧠🛡️
“型で守る”の基本方針はこの3段ロケット🚀🚀🚀
A) 変な状態を「型で作れない」ようにする🧱✨
代表例:金額はマイナス禁止、空文字禁止、などなど🙅♀️
B) 失敗は throw じゃなく「返り値」で表現する🎁
Result(成功/失敗のユニオン)にして、差し替えても制御フローが揺れないようにする✨
C) “契約”は「共通テスト」で固定する🧪🔒
型がOKでも、意味がズレることはあるからね🥲 → そこで「どの実装でも同じテストを通る」を作るよ✅
実戦題材:Campus Café の「支払い方法」☕️💳✨
「支払い方法(現金/クレカ/PayPay…)」って増えがちで、差し替えが起きがちで、LSPの練習に最高〜!🎯💕
ここから、“差し替え口(interface)”を作って、型とテストで置換可能性を守るよ🧩🛡️
1) まずは「金額」を型で守ろう💰🔐
「numberでいいじゃん」と思うけど、numberは何でも入るから事故が起きる😇 なので ブランド型(branded type) で “ただのnumber” と区別しよ〜🪄✨
// Money は「円(0以上の整数)」だけを表す型にするよ💰
type Money = number & { readonly __brand: "Money" };
type MoneyError =
| { type: "Negative"; value: number }
| { type: "NotInteger"; value: number }
| { type: "NaN"; value: number };
function createMoney(value: number): { ok: true; value: Money } | { ok: false; error: MoneyError } {
if (Number.isNaN(value)) return { ok: false, error: { type: "NaN", value } };
if (!Number.isInteger(value)) return { ok: false, error: { type: "NotInteger", value } };
if (value < 0) return { ok: false, error: { type: "Negative", value } };
return { ok: true, value: value as Money };
}
これで何が嬉しいかというと…👇
Moneyを受け取る関数は、「0以上の整数」だと信じていい😊- そのかわり
createMoneyで作る時に、入口で弾く✅
つまり 「前提条件」を型に寄せるってことだよ🧠✨
2) 失敗は Result で返す(throwしない)🎁🧯
支払いって失敗するよね?(残高不足とか)😢
それを throw にすると、実装ごとにバラついて地獄になりがち🔥
なので Result を作るよ〜💕
type Ok<T> = { ok: true; value: T };
type Err<E> = { ok: false; error: E };
type Result<T, E> = Ok<T> | Err<E>;
function ok<T>(value: T): Ok<T> {
return { ok: true, value };
}
function err<E>(error: E): Err<E> {
return { ok: false, error };
}
3) 差し替え口(interface)を「契約」として設計する🧩📜
今回の契約はこんな感じにするよ👇 (ポイントは “どの実装でも守れる” ちょうどよい約束 にすること!)
type PaymentError =
| { type: "Unsupported"; reason: string }
| { type: "Declined"; reason: string }
| { type: "TemporaryFailure"; reason: string };
type PaymentReceipt = {
methodId: string;
paid: Money;
message: string;
};
type PaymentRequest = {
total: Money;
};
interface PaymentMethod {
readonly id: string;
// これは「できる/できない」を判定するだけ(副作用なし)✨
canHandle(req: PaymentRequest): boolean;
// 失敗は Result で返す(throwしない)🧯
pay(req: PaymentRequest): Promise<Result<PaymentReceipt, PaymentError>>;
}
ここでLSP的に超大事な“契約の文章化”をしておくと強いよ📝✨ (コメントでもREADMEでもOK)
canHandleは 判定だけ(支払い処理しない)payは 絶対にthrowしない(全部Resultで返す)- 成功したら receipt の
paidは 必ず req.total と一致(今回は簡単に一致に統一!)
この「成功条件」を揃えるのが、置換可能性のコアだよ🧠🛡️
4) 実装を2つ作る(現金・カード)💴💳✨
現金(いつでもOK)💴
class CashPayment implements PaymentMethod {
readonly id = "cash";
canHandle(_req: PaymentRequest): boolean {
return true;
}
async pay(req: PaymentRequest): Promise<Result<PaymentReceipt, PaymentError>> {
// 現金は今回は「必ず成功」とする(例として簡単に😊)
return ok({
methodId: this.id,
paid: req.total,
message: "現金でお支払いしました💴✨",
});
}
}
カード(例:上限あり・通信失敗あり)💳📡
class CardPayment implements PaymentMethod {
readonly id = "card";
canHandle(req: PaymentRequest): boolean {
// 例:カードは 100万円未満まで…みたいな上限ルール(仮)
return req.total < (1_000_000 as Money);
}
async pay(req: PaymentRequest): Promise<Result<PaymentReceipt, PaymentError>> {
if (!this.canHandle(req)) {
return err({ type: "Unsupported", reason: "カード上限を超えています💦" });
}
// 例:たまに通信失敗する想定(仮)
const dice = Math.random();
if (dice < 0.05) {
return err({ type: "TemporaryFailure", reason: "通信が不安定です📡💦 もう一度試してね" });
}
return ok({
methodId: this.id,
paid: req.total,
message: "カードでお支払いしました💳✨",
});
}
}
ここがLSP的にえらい👏✨
- 上限超えを throw じゃなく Unsupportedとして返す
canHandleとpayの整合がある(できないならUnsupported)- 成功時の
paidのルールが揃ってる(現金でもカードでも同じ)
5) 「LSP違反」をわざと作ってみよう😈💥
やりがちな事故:実装だけ勝手に throw しちゃうやつ🔥
class BadCardPayment implements PaymentMethod {
readonly id = "bad-card";
canHandle(_req: PaymentRequest): boolean {
return true; // なんでもOKと言っておいて…
}
async pay(req: PaymentRequest) {
// ここで throw しちゃう😇(呼び出し側が崩壊する)
if (req.total > (10_000 as Money)) {
throw new Error("限度額オーバー!");
}
return ok({ methodId: this.id, paid: req.total, message: "OK" });
}
}
これ、呼び出し側が Result 前提で書いてると即死するよね🥲
「差し替えた瞬間に例外が飛ぶ」= 置換できてないってこと💣
6) 共通テストで「契約」を固定する🧪🔒✨(ここが本番)
ここからが第19章のメインディッシュ🍰✨ “どの実装でも同じテストが通る” を作って、置換可能性を守るよ!
Vitestは4.0が出てて、いまのTS案件でも採用増えてるよ〜🧪✨ (Vitest)
テスト用の「実装リスト」を用意する📦
const methods: PaymentMethod[] = [
new CashPayment(),
new CardPayment(),
// new BadCardPayment(), // ← 入れるとテストで落ちて気持ちいい😈💥
];
“契約テスト” を書く(全実装に対して同じテスト)🧪✨
import { describe, test, expect } from "vitest";
function mustNotThrow<T>(fn: () => Promise<T>): Promise<{ ok: true; value: T } | { ok: false; error: unknown }> {
return fn()
.then((value) => ({ ok: true as const, value }))
.catch((error) => ({ ok: false as const, error }));
}
describe("PaymentMethod contract tests 🧪✨", () => {
test("全実装: pay は throw しない(Resultで返す)🔥→🧯", async () => {
const m = createMoney(500);
if (!m.ok) throw new Error("test setup failed");
for (const method of methods) {
const outcome = await mustNotThrow(() => method.pay({ total: m.value }));
expect(outcome.ok).toBe(true);
}
});
test("全実装: canHandle=false なら Unsupported を返す(契約)🧩", async () => {
const big = createMoney(2_000_000);
if (!big.ok) throw new Error("test setup failed");
for (const method of methods) {
const req = { total: big.value };
if (method.canHandle(req)) continue;
const res = await method.pay(req);
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.error.type).toBe("Unsupported");
}
}
});
test("全実装: 成功したら paid は total と一致する💰✅", async () => {
const m = createMoney(1200);
if (!m.ok) throw new Error("test setup failed");
for (const method of methods) {
const req = { total: m.value };
if (!method.canHandle(req)) continue;
const res = await method.pay(req);
if (res.ok) {
expect(res.value.paid).toBe(req.total);
expect(res.value.methodId).toBe(method.id);
}
}
});
});
これで何が守れるの?😍
- throwしない(差し替えても呼び出し側が壊れない)
canHandleとpayの整合がズレたら落ちる- 成功時の意味(paidのルール)を共通化できる
→ 置換可能性が“テストで保証”されるのが最高なんだよね🧠🛡️🧪✨
7) TypeScriptの小ワザ:satisfies で “形はOK” を安全に確認🧷✨
たとえば実装を class じゃなくオブジェクトで用意したい時、
as PaymentMethod でゴリ押しすると危ない😇
そんな時は satisfies が便利だよ💡(TS 4.9で入ったやつ!) (TypeScript)
const paypay = {
id: "paypay",
canHandle: (req: PaymentRequest) => req.total < (300_000 as Money),
pay: async (req: PaymentRequest) => ok({ methodId: "paypay", paid: req.total, message: "PayPay✨" }),
} satisfies PaymentMethod;
satisfies は
- 「PaymentMethodを満たしてるか」チェックしつつ✅
- 変数自体の型推論は潰さない(余計に widen しない)✨
って感じで、契約チェックにちょうどいいんだ〜😊
8) よくある「置換できない」設計の匂いチェック👃💥
次のどれかが見えたら、LSP赤信号〜🚨
canHandleが true なのに、payで Unsupported を返す(整合崩れ)😵- ある実装だけ
payが throw する🔥 - ある実装だけ成功時に
paidの意味が違う(手数料込み/別…)💸 - ある実装だけ「ログ保存」や「DB更新」を勝手にやる🧨
9) ミニ課題(やってみよ〜!)🎓✨
課題A:PayPayPayment を追加しよう📱✨
canHandle:30万円未満だけOKpay:たまにTemporaryFailureを返す(throw禁止)🧯
課題B:契約テストを1本追加しよう🧪
例:
- 成功時
messageは空文字禁止(NonEmptyString型にしてもOK!)📝✨
10) AI活用(うまい使い方だけ置いとくね🤖💡)
Copilot / Codex系に投げるなら、こんな感じが強いよ〜💪✨
- 「
PaymentMethodの contract tests を vitest で書いて。条件:payはthrowしない、成功時paidはtotalと一致」 - 「LSP違反になりやすいケースをこの設計で3つ挙げて、テスト追加案も出して」
でも!最後は必ず👀✨
- 契約(成功条件・失敗条件)が文章として筋が通ってるか を人間がチェックしてね🫶(AIは“それっぽい嘘”も混ぜるから🥲)
まとめ🌸✨
この章でやったことはコレだよ🎁
- LSPは「差し替えた瞬間に壊れない」こと🧩
- TypeScriptでは interfaceが差し替え口になるから、契約設計が命📜
- **型(Money/Result/satisfies)**で置換可能性を強くする🧠🛡️
- 最後は **共通テスト(契約テスト)**で“意味”を固定する🧪🔒
次の章(ISP)に行くと、「差し替え口がデカすぎて置換できない」問題をスパッと切れるようになるよ✂️😊✨