第17章:LSP違反の典型パターン集(あるある)📛🧩
(=「差し替えたら壊れる」を未然に防ぐコーナーだよ〜!🫶✨)
この章のゴールはシンプル💡 **「interface / 継承で“型が合ってる”のに、実行すると壊れる」**の典型を知って、早めに気づける目👀を作ることだよ〜🙂↕️🌸 LSPは「型の互換」じゃなくて、振る舞い(約束)の互換がテーマ!📜✨ (ウィキペディア)

0) まず最重要:LSP違反って、何が“困る”の?😵💫💥
たとえば DiscountPolicy を差し替えられる設計にしたのに…
- Aの割引は動く✅
- Bの割引に差し替えたら 例外で落ちる💥
- Cの割引は 値がマイナスで合計がバグる😇
- Dの割引は 注文を勝手に書き換える😱
これ、全部「置換できてない」=LSP違反の香り👃💨
1) 典型①:前提条件を“強くする”(入力にうるさくなる)🚫🧨
親(契約)よりも、子(実装)が要求を増やすやつ!
あるある例💡
「割引計算はどんな注文でも呼べる(該当しなければ0円)」が契約なのに… 子が「学生じゃない注文が来たら例外」みたいにしてしまう😵
type Order = { total: number; customerType: "student" | "normal" };
interface DiscountPolicy {
calcDiscount(order: Order): number; // 契約:どんな order でも呼べる想定
}
class StudentOnlyDiscount implements DiscountPolicy {
calcDiscount(order: Order): number {
if (order.customerType !== "student") {
throw new Error("学生以外は呼ばないで!"); // ❌ 前提条件を強化しちゃってる
}
return Math.floor(order.total * 0.2);
}
}
直し方(定番)✅✨
「該当しないなら0円」に寄せる(=契約を守る)🫶
class StudentOnlyDiscount implements DiscountPolicy {
calcDiscount(order: Order): number {
if (order.customerType !== "student") return 0; // ✅ 契約どおり:呼べる
return Math.floor(order.total * 0.2);
}
}
チェックの合言葉🎯
- 「この実装、呼べるケースが減ってない?」👀
2) 典型②:事後条件を“弱くする”(出力の品質が下がる)📉😇
親(契約)が保証してた結果を、子が保証しなくなるやつ!
あるある例💡
契約:0〜注文合計までの割引額を返す
なのに、子が -100 とか NaN とか返す💥
class BuggyDiscount implements DiscountPolicy {
calcDiscount(order: Order): number {
return -100; // ❌ ありえない(割引はマイナスにならない契約のはず)
}
}
直し方(定番)✅✨
- 返して良い範囲を“契約として明文化”(コメントでもテストでもOK)
- 実装側でガードする🛡️
class SafeDiscount implements DiscountPolicy {
calcDiscount(order: Order): number {
const raw = Math.floor(order.total * 0.1);
const clamped = Math.max(0, Math.min(raw, order.total));
return clamped; // ✅ 0..total を守る
}
}
チェックの合言葉🎯
- 「この実装、返す値の範囲が雑になってない?」🧮
3) 典型③:例外を“増やす / 種類を変える”(呼び出し側が死ぬ)💣😵
LSPの有名ルールのひとつに **「子が新しい例外を投げ始めるな」**系の話があるよ(呼び出し側が想定できないから)📛 (ウィキペディア)
あるある例💡
「割引計算は落ちない」前提で使ってたのに、急に落ちる💥
class SometimesThrowsDiscount implements DiscountPolicy {
calcDiscount(order: Order): number {
if (order.total > 100_000) throw new Error("高額すぎ!"); // ❌
return 500;
}
}
直し方(おすすめ)✅✨
“落ちうる”契約にしたいなら、型で表現しちゃうのが強い💪
type DiscountResult =
| { ok: true; discount: number }
| { ok: false; reason: string };
interface DiscountPolicy2 {
calcDiscount(order: Order): DiscountResult;
}
チェックの合言葉🎯
- 「差し替えたら try/catch が必要になった」←それ違反の匂い👃💨
4) 典型④:意味を“すり替える”(同じメソッド名なのに別物)🎭😱
型は合ってる。でも人間の期待がズレるやつ!
あるある例💡
契約:calcDiscount は「割引“金額”を返す(円)」
なのに子が「割引“率”」を返す(0.2 とか)😇
class RateDiscount implements DiscountPolicy {
calcDiscount(order: Order): number {
return 0.2; // ❌ 金額のはずなのに率を返してる
}
}
直し方✅✨
型で区別するのが最強🥇(お作法:ブランド型)
type Yen = number & { readonly __brand: "Yen" };
type Rate = number & { readonly __brand: "Rate" };
interface AmountDiscountPolicy {
calcDiscountYen(order: Order): Yen;
}
interface RateDiscountPolicy {
calcDiscountRate(order: Order): Rate;
}
チェックの合言葉🎯
- 「この戻り値、単位がズレてない?」💴📏
5) 典型⑤:不変条件(invariant)を壊す(親が守ってた世界を壊す)🧱💥
LSPは「前提/事後」だけじゃなく 不変条件も大事だよ〜🧠✨ (ウィキペディア)
あるある例💡
契約:注文は計算中に書き換えない(読み取り専用) なのに子が、こっそり注文を書き換える😱
type Order2 = { total: number; notes: string[] };
interface DiscountPolicy3 {
calcDiscount(order: Order2): number; // 契約:orderを変更しない想定
}
class MutatingDiscount implements DiscountPolicy3 {
calcDiscount(order: Order2): number {
order.notes.push("割引したよ!"); // ❌ 呼び出し側の想定を破壊
return 100;
}
}
直し方✅✨
- 入力をイミュータブルに寄せる
- 変更したいなら「戻り値」に出す
type DiscountWithNote = { discount: number; note?: string };
interface DiscountPolicy4 {
calcDiscount(order: Readonly<Order2>): DiscountWithNote;
}
チェックの合言葉🎯
- 「差し替えたら、呼び出し後に引数の中身が変わってた」←だいぶ危険⚠️
6) 典型⑥:呼び出し順の“隠しルール”を増やす(状態マシン化)🔁🫠
「先に init() 呼んでね!」みたいな 隠し前提を足すと置換できない😵
あるある例💡
A実装はそのまま使えるのに、B実装は「先に準備が必要」になる
interface Notifier {
notify(message: string): void;
}
class LazyInitNotifier implements Notifier {
private ready = false;
init() { this.ready = true; }
notify(message: string) {
if (!this.ready) throw new Error("initしてから!"); // ❌ 隠し前提
console.log(message);
}
}
直し方✅✨
init()が必要なら 別の抽象に分ける(ISPっぽいね✂️)- もしくは 生成時に準備完了にする(DIで解決しがち💉)
7) TypeScript特有の罠:型システムが“見逃す”置換不能(特にメソッド)🕳️😵💫
ここ、2026のTSでも「油断ポイント」だから知っておくと強いよ💪✨
ポイント🧠
--strictFunctionTypes は関数型を厳しくしてくれるけど、メソッド宣言由来の関数は対象外になりやすい(互換性のため)って事情があるよ〜📌 (TypeScript)
「実装が受け取れる引数が減ってる(=前提条件強化)」みたいな危険が、 型だけだとスルッと通ることがある😇
対策の方向性✅✨
- コールバックは **メソッドじゃなく“関数プロパティ”**で持つ(厳しく効かせやすい)
- 契約は テストで守る(次の章の“共通テスト”にもつながるよ🧪)
8) 継承を使うなら:override と noImplicitOverride は味方だよ🛡️✨
「つもりでオーバーライドしてなかった」みたいな事故を減らせるやつ!
TypeScriptの noImplicitOverride を使うと、オーバーライドには override を必須にできるよ✅ (TypeScript)
(※これで防げるのは“形の事故”で、振る舞いのLSPはやっぱテスト・契約で守るのが大事だよ〜🙂↕️)
最強の守り:契約テスト(“どの実装でも通るテスト”)🧪✨
LSPは最終的にここが強い!💪 「差し替え可能」って言うなら、同じテストを全実装に流すのが超効くよ🎯
// どの DiscountPolicy でも守るべき契約テスト(例)
function contractTest(policy: DiscountPolicy, order: Order) {
const d = policy.calcDiscount(order);
if (!Number.isFinite(d)) throw new Error("discountは有限数!");
if (d < 0) throw new Error("discountはマイナス禁止!");
if (d > order.total) throw new Error("discountがtotal超えた!");
}
// 実装ごとに同じ契約テストを流す
const policies: DiscountPolicy[] = [
new StudentOnlyDiscount(),
new SafeDiscount(),
];
for (const p of policies) {
contractTest(p, { total: 1000, customerType: "normal" });
contractTest(p, { total: 1000, customerType: "student" });
}
ミニ課題(3つ)🎒✨
課題A:前提条件強化を見つけよう🔍
- 「この実装、受け取れる入力が減ってない?」を探して
- 例外を0円返却に変えるか、Result型にするか、どっちかで直してね🙂↕️
課題B:意味すり替えを型で防ごう🧷
- 「金額」と「率」をブランド型で分けて
- 間違えるとコンパイルで落ちるようにしてみてね💥✨
課題C:契約テストを1本増やそう🧪
discount % 10 === 0みたいな“勝手ルール”を足してる実装がいたら落ちるようにしてみてね😈(※やりすぎ注意!笑)
まとめ:LSP違反あるあるチェックリスト✅👀
- 入力にうるさくなってない?(前提条件強化)🚫
- 戻り値の品質が落ちてない?(事後条件弱化)📉
- 例外が増えてない?💣
- 単位・意味が変わってない?🎭
- 引数や内部状態をこっそり変えてない?🧨
- 呼び出し順の隠しルールない?🔁
- TSの型だけで安心してない?(振る舞いはテスト!)🧪
おまけ:いまのTypeScript事情メモ📝✨
現時点の安定版は TypeScript 5.9 系だよ〜(インストール案内でも “currently 5.9” になってるよ)📌 (TypeScript)
なので、この章のテク(noImplicitOverride とか)も 現役で使えるやつだよ🫶✨
次の章(第18章)では、このLSP違反を減らす最強ムーブ 「継承より合成」🧱🤝 を、もっと実戦っぽくやっていくよ〜!☕️📦✨