第12章:OCP(拡張に開く、修正に閉じる)🚪✨
今日は OCP を、ミニプロジェクトの「クーポン割引」を題材にして、めちゃ体感で覚えちゃおう〜😊🎟️
ちなみに 2026/01/09 時点で、TypeScript の最新リリースノートは TypeScript 5.9 が更新されてるよ(ドキュメント更新日も表示されてる)📌 (TypeScript)
npm の typescript も latest 5.9.3 として掲載されてるよ✨ (npm)

この章でできるようになること ✅🌟
- 「機能追加のたびに switch を編集する地獄😵」を、設計で回避できるようになる
- どこが変わりやすいかを見抜いて、拡張ポイントを作れる
- 追加機能を 既存コードほぼノータッチで入れられる形にできる
- AI にリファクタ案を出させつつ、人間が安全に採用判断できる🤖🧠
OCPってなに? ざっくり一言で 🍰✨
「新しい機能を足すとき、すでに安定してるコードはなるべく触らない」 だよ😊🛡️
- 拡張に開く:新しい割引ルールを “追加” できる
- 修正に閉じる:既存の計算ロジック(安定してる場所)は “編集” しない
ポイントはこれ👇 「変更が起きる場所」を決めて、そこに集める 🧲✨ (ゼロ修正が理想だけど、現実は「修正が最小」でも大勝利だよ〜🎉)
まずは OCP違反あるあるを見よう 👃💥
あるある症状トップ3 🥺
switch (coupon.type)が育ちすぎる🌳- クーポン追加のたびに 同じファイルを毎回編集😇
- 1つの関数が「全クーポンの知識」を抱えて太る🍔
これ、最初はラクなんだけど… 追加が増えるほど壊しやすいし、レビューも怖いし、テストも増やしにくいの🥲
お題 ミニプロジェクトのクーポン割引 ☕️🎟️
「Campus Café」で、注文合計にクーポンを適用するよ〜!
まずはダメになりやすい実装 😵💫
// ❌ OCP的につらくなりやすい例:追加のたびにここを編集する
export type Coupon =
| { type: "STUDENT"; percentOff: number }
| { type: "RAINY"; amountOff: number }
| { type: "SET"; amountOff: number };
export function applyCoupon(total: number, coupon: Coupon): number {
switch (coupon.type) {
case "STUDENT":
return Math.max(0, total - total * (coupon.percentOff / 100));
case "RAINY":
return Math.max(0, total - coupon.amountOff);
case "SET":
return Math.max(0, total - coupon.amountOff);
default: {
// ここに来ないはず…のはず…😇
const _exhaustive: never = coupon;
return total;
}
}
}
これが何で困るの? 🤔
- クーポンが増えるたび、毎回この関数を編集する
- 間違って既存ケースを壊すリスクが増える
- 「仕様の追加」なのに「既存の重要ロジック」を触ることになる🧨
OCPの解決イメージ 変わるものを外に出す 🧳✨
OCPのコツはこれ👇
1 変わりやすいところを見つける 🔎
この例だと 割引の種類が増えるのが未来で濃厚だよね🎟️📈
2 差し替え口を作る 🧩
「割引の計算」を インターフェースにする✨
3 追加は新しいファイルを増やすだけ 📁
既存の計算本体はなるべく触らない💪
改善版 Strategy風にして OCPを満たす 🧠🔁✨
フォルダ案 📁✨
src/domain/discount/DiscountPolicy.tssrc/domain/discount/policies/StudentDiscountPolicy.tssrc/domain/discount/policies/RainyDiscountPolicy.tssrc/domain/discount/policies/SetDiscountPolicy.tssrc/app/discount/discountRegistry.ts← ここが「追加の入口」になりやすい🎯src/domain/discount/applyDiscount.ts
1 割引の差し替え口を作る 🧩
// src/domain/discount/DiscountPolicy.ts
export type DiscountKind = string;
export type DiscountContext = {
total: number;
};
export interface DiscountPolicy {
kind: DiscountKind;
apply(ctx: DiscountContext): number; // 返すのは「割引後の合計」
}
2 各割引をクラスとして追加していく 🎟️✨
// src/domain/discount/policies/StudentDiscountPolicy.ts
import type { DiscountPolicy, DiscountContext } from "../DiscountPolicy";
export class StudentDiscountPolicy implements DiscountPolicy {
public readonly kind = "STUDENT";
constructor(private readonly percentOff: number) {}
apply(ctx: DiscountContext): number {
return Math.max(0, ctx.total - ctx.total * (this.percentOff / 100));
}
}
// src/domain/discount/policies/RainyDiscountPolicy.ts
import type { DiscountPolicy, DiscountContext } from "../DiscountPolicy";
export class RainyDiscountPolicy implements DiscountPolicy {
public readonly kind = "RAINY";
constructor(private readonly amountOff: number) {}
apply(ctx: DiscountContext): number {
return Math.max(0, ctx.total - this.amountOff);
}
}
(Set も同じ感じで OK だよ〜🧸✨)
3 ドメイン側は “知らない” で通す 😎🛡️
// src/domain/discount/applyDiscount.ts
import type { DiscountPolicy, DiscountContext, DiscountKind } from "./DiscountPolicy";
export function applyDiscount(
total: number,
kind: DiscountKind,
policies: Map<DiscountKind, DiscountPolicy>
): number {
const policy = policies.get(kind);
if (!policy) return total; // 未登録なら何もしない(仕様次第でエラーでもOK)
const ctx: DiscountContext = { total };
return policy.apply(ctx);
}
✅ ここ大事!
applyDiscount は STUDENT とか RAINY とかの具体名を知らない。
だから新しい割引が増えても、ここは触らなくてよくなるの🎉
4 追加の入口を “1か所” に集める 🧲✨
// src/app/discount/discountRegistry.ts
import type { DiscountKind, DiscountPolicy } from "../../domain/discount/DiscountPolicy";
import { StudentDiscountPolicy } from "../../domain/discount/policies/StudentDiscountPolicy";
import { RainyDiscountPolicy } from "../../domain/discount/policies/RainyDiscountPolicy";
// import { SetDiscountPolicy } from ...
export function buildDiscountPolicies(): Map<DiscountKind, DiscountPolicy> {
// ✅ ここが「拡張ポイント」になりやすい
// 新しい割引を追加するときは、基本ここに “登録を足す” だけ
return new Map<DiscountKind, DiscountPolicy>([
["STUDENT", new StudentDiscountPolicy(10)],
["RAINY", new RainyDiscountPolicy(100)],
// ["SET", new SetDiscountPolicy(50)],
]);
}
ここを「追加の入口」にしておくと、変更が散らばらないよ😊✨ (のちの章でやる DI/DIP にもつながっていくよ〜💉🌈)
動作イメージ 🧪✨
import { buildDiscountPolicies } from "./app/discount/discountRegistry";
import { applyDiscount } from "./domain/discount/applyDiscount";
const policies = buildDiscountPolicies();
const total = 1200;
const afterStudent = applyDiscount(total, "STUDENT", policies);
const afterRainy = applyDiscount(total, "RAINY", policies);
console.log({ afterStudent, afterRainy });
テストで「拡張しても壊れない」を守る ✅🛡️
Vitest は 4.0 が出てるよ〜⚡(公式のアナウンスあり) (vitest.dev)
// src/domain/discount/applyDiscount.test.ts
import { describe, it, expect } from "vitest";
import { buildDiscountPolicies } from "../../app/discount/discountRegistry";
import { applyDiscount } from "./applyDiscount";
describe("applyDiscount", () => {
it("STUDENTが10%オフになる", () => {
const policies = buildDiscountPolicies();
expect(applyDiscount(1000, "STUDENT", policies)).toBe(900);
});
it("RAINYが100円引きになる", () => {
const policies = buildDiscountPolicies();
expect(applyDiscount(1000, "RAINY", policies)).toBe(900);
});
it("未登録の割引は何もしない", () => {
const policies = buildDiscountPolicies();
expect(applyDiscount(1000, "UNKNOWN", policies)).toBe(1000);
});
});
演習 ミッション3本 🎯🎉
ミッション1 新クーポン BIRTHDAY を追加 🎂🎟️
要件:合計から 200円引き やること:
BirthdayDiscountPolicy.tsを追加discountRegistry.tsに登録を1行追加- テストを1本追加してグリーンに✅
👉 成功したら、「既存の applyDiscount を触らずに追加できた!」ってなるはず🥳✨
ミッション2 ルール変更の怖さを体験 🧨➡️🛡️
RAINYを「雨の日は 100円引き」から「150円引き」に変更してみてね- 変更箇所がどこか、すぐ分かる?👀 (分かりやすい=設計の勝ち🌸)
ミッション3 仕様を増やしても switch を増やさない 🔁✨
applyDiscountの中にswitchを絶対書かない縛りでやってみてね😆
よくある勘違いコーナー 🤯💡
勘違い1 OCPは一切修正しない原則
→ 現実は「修正を最小化」できればOKだよ😊 安定した重要ロジックを守るのが目的🛡️
勘違い2 なんでも interface にすれば強い
→ 早すぎ抽象化はコスト🍔💸 「増えそう」「変わりそう」が見えたところだけでOK👌
AI活用 うまい使い方テンプレ 🤖📝✨
追加に強いリファクタ依頼
- 「この switch を OCPに沿って Strategy にして」
- 「差し替え口(interface)と registry を作って」
- 「変更は小さなステップで、テストも一緒に」
差分レビューのお願い
- 「変更ファイル一覧を出して」
- 「既存仕様が壊れる可能性を指摘して」
- 「テストケースを3つ提案して」
AI は提案が得意だけど、採用判断は人間がやるのが安全だよ〜👩💻🧠✨
まとめ 今日の持ち帰り 🎒✨
- OCPは「追加のたびに既存の重要コードを触らない」ための考え方🚪🛡️
switch/ifが増えだしたら、変わる部分を外に出すサイン👃💥- 「差し替え口」+「登録場所を1か所」にすると、拡張がラクになる🎟️📦
- テストがあると、拡張しても怖くない✅
次の第13章では、この流れをもっと王道の形にして、戦略パターンで差し替えを気持ちよく極めるよ〜🧠🔁✨