第10章:型で境界を守る(ドメイン型の作り方)🧠🛡️✨
0. この章でできるようになること🎯💖
stringやnumberの 乱用事故(ID取り違え・金額取り違え・日付の地獄)を 型で止める💥🧯- 「入力(外側)」→「検証」→「ドメイン型(内側)」の 変換パイプラインを作れる🔄✨
- TypeScriptの 構造的型付けのクセを理解して、必要なところにだけ“名前っぽさ”を足せる🐣➡️🦉 (typescriptlang.org)
satisfiesを使って「境界の設定オブジェクト」を安全に保てる✅🧩 (typescriptlang.org)
1. まず「string乱用」って何がヤバいの?😇💥
ありがちな事故(全部コンパイル通っちゃうやつ)🫠
userId: stringとeventId: stringを取り違えるprice: number(円)とpoint: number(ポイント)を混ぜるdate: stringに"2026/13/40"みたいなのが混ざる- APIから来たデータ(DTO)をそのままドメインで使って、あとで泣く😭
TypeScriptは 構造(shape)が同じならOKって判定しがちだから、意味が違っても string は string なんだよね…🌀 (typescriptlang.org)
2. ドメイン型ってなに?🛡️(境界の“通行証”🎫✨)

**ドメイン型 = 「内側(Domain)で使っていいと保証された形」**だよ✨ 外側(UI入力・API・DB)から来たデータは、だいたい信用できない😇 だから境界でこうする👇
- 外側の値(たいてい
string)を受け取る📥 - ルールで検証する🔍
- OKなら「ドメイン型」に変換して内側へ渡す🚪➡️🛡️
検証(境界の関心) と 業務ルール(ドメインの関心) を混ぜにくくなるよ🧼✨
3. 作り方その①:Branded Types(疑似“名前的型”)🏷️✨

TypeScriptは基本 構造的型付けだけど、
「同じ string でも意味が違う」問題には **ブランド(タグ)**を付けて区別するのが定番👍
(TypeScript公式のPlaygroundでも “Nominal Typing” 例があるよ) (typescriptlang.org)
3-1. 最小セットの Brand ヘルパー🧰
// domain/brand.ts
declare const brandSymbol: unique symbol;
export type Brand<T, TBrand extends string> = T & { readonly [brandSymbol]: TBrand };
3-2. IDを「取り違え不可能」にする🆔🛡️
// domain/ids.ts
import { Brand } from "./brand";
export type UserId = Brand<string, "UserId">;
export type EventId = Brand<string, "EventId">;
// “作れるのはここだけ” にするのがコツ✨
export function parseUserId(input: string): UserId | null {
const s = input.trim();
if (!/^u_[a-z0-9]{8}$/i.test(s)) return null;
return s as UserId;
}
export function parseEventId(input: string): EventId | null {
const s = input.trim();
if (!/^e_[a-z0-9]{8}$/i.test(s)) return null;
return s as EventId;
}
3-3. 事故が“型で”止まる💥✋
import { UserId, EventId } from "./domain/ids";
function joinEvent(userId: UserId, eventId: EventId) {
// ...
}
const uid = "u_12ab34cd"; // 外側
const eid = "e_98fe76dc"; // 外側
// joinEvent(uid, eid); // ❌ まだ外側のstringなので渡せない
// まず境界で変換✨
4. 作り方その②:Value Object(小さなクラスで“ルールごと”包む)📦💖
ブランド型は軽い!最高!✨ でも「値に振る舞い(計算・表示・比較)が欲しい」ときは Value Object が便利🍰
例:金額(円)を “ただの number” から卒業💸🎓
// domain/money.ts
export class Yen {
private constructor(private readonly value: number) {}
static parse(input: number): Yen | null {
if (!Number.isInteger(input)) return null;
if (input < 0) return null;
return new Yen(input);
}
toNumber(): number {
return this.value;
}
add(other: Yen): Yen {
return new Yen(this.value + other.value);
}
}
✅ これで「負の金額」や「小数の円」が内側に入るのを止められる👏✨
5. 作り方その③:Union(状態を “ありえる形” だけに絞る)🧩✨
「状態」は Union がすごく強い💪 たとえば申込み結果:
export type ApplyResult =
| { type: "accepted" }
| { type: "rejected"; reason: "sold_out" | "age_limit" | "invalid_email" };
こうすると reason を出し忘れたり、変な文字列を入れたりしにくい🙆♀️✨
6. 作り方その④:Template Literal Types(フォーマットを型で縛る)🧵🪡
「文字列の形」をある程度しばれるのがテンプレートリテラル型✨ (typescriptlang.org)
// 例:ISOっぽい形(※これだけで日付の妥当性100%は保証できないよ!)
export type IsoDateString = `${number}-${number}-${number}`;
⚠️ 大事:型だけで完全な妥当性チェックは無理なことが多い! だから「型 + 解析(runtime検証)」のセットがいちばん安定🧸✨
7. “境界で変換” の基本形(Result型でやさしく失敗を扱う)🌸
7-1. Result型(超よく使う!)📦
export type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
export const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
export const err = <E>(error: E): Result<never, E> => ({ ok: false, error });
7-2. 入力 → 検証 → ドメイン型 変換の例✍️➡️🛡️
import { Brand } from "./brand";
import { Result, ok, err } from "./result";
export type Email = Brand<string, "Email">;
export function parseEmail(input: string): Result<Email, "invalid_email"> {
const s = input.trim();
// 超ゆるい例(本番は要件に合わせてね)
if (!s.includes("@")) return err("invalid_email");
return ok(s as Email);
}
8. satisfies で “境界の設定” を壊れにくくする🧷✨
satisfies は 「この形を満たしてる?」をチェックしつつ、推論結果は潰さないのが強み💖 (typescriptlang.org)
たとえば、エラー表示の辞書を作るとき👇
type ErrorCode = "invalid_email" | "sold_out";
const messages = {
invalid_email: "メールの形が変かも…🥺",
sold_out: "ごめんね、満席だった…💦",
// typo: "soldout": "..." みたいなミスを防げる!
} satisfies Record<ErrorCode, string>;
9. ミニ演習:イベント申込み(入力→検証→ドメインへ)🎓📮✨
お題📝
フォームから来る入力はこう👇(外側)
studentId: stringemail: stringticketCount: string(数値っぽいけど文字列)
これを境界で変換して、ドメイン関数に渡してね💪🌸
9-1. ドメイン型(例)🧠
StudentId(Brand)Email(Brand)TicketCount(Value Object or Brand)
9-2. ドメイン関数は「生のstring禁止」🚫🧻
type StudentId = import("./ids").UserId; // 例:実際は StudentId を別に作ってね
type Email = import("./email").Email;
export function canApply(args: { studentId: StudentId; email: Email; count: number }): boolean {
// 例:2枚まで
return args.count >= 1 && args.count <= 2;
}
9-3. 境界(Application/UI側)で変換してから呼ぶ🔄
parseStudentIdparseEmailparseTicketCount
ここを作れたら勝ち🏆✨
10. テスト(境界の変換はテストしやすい!)🧪🌸
変換系は 純粋処理に寄せやすいから、テストのコスパが最強💪✨
例:Nodeの node:test でサクッと
// test/email.test.ts
import test from "node:test";
import assert from "node:assert/strict";
import { parseEmail } from "../src/domain/email";
test("parseEmail: OK", () => {
const r = parseEmail("a@b.com");
assert.equal(r.ok, true);
});
test("parseEmail: NG", () => {
const r = parseEmail("not-an-email");
assert.equal(r.ok, false);
});
11. AI活用ミニコーナー🤖🎁(めっちゃ相性いい!)
使えるプロンプト例✨
- 「
string乱用になってる場所を洗い出して、ドメイン型候補を10個出して」🔍 - 「
UserId/EventIdの brand型 + parse関数 を作って。バリデーションは正規表現で」🧩 - 「この
parseTicketCountの 境界値テストケースを列挙して」🧪 - 「DTO→Domain変換の責務が混ざってないかレビューして、改善案を3つ」🧼
12. よくある落とし穴(ここだけ避けて!)🚧😵💫
- ドメイン型を作ったのに、結局
asでねじ込む(最悪の抜け道🥲) →asは “境界の中だけ” に閉じ込めよう🔒 - 型だけあって runtime検証がない → 外側は信用しない!入力は必ずチェック🔍
- 作りすぎてしんどい → まずは事故が多いところ(ID・金額・日付・メール)からでOK🍰
13. 理解チェッククイズ🎀(答えは下にあるよ👇)
stringをドメインでそのまま使うと何が起きやすい?😇- ドメイン型への変換はどこに置くのが気持ちいい?🚪
satisfiesの嬉しいポイントは?✨- Template Literal Types だけで日付の妥当性は保証できる?📅
こたえ✅
- 取り違え事故(ID/金額/日付)や、DTOの都合がドメインに侵入しやすい💥
- 境界(UI/API/DBに近い層)で「検証→変換」してから内側へ🛡️
- 形のチェックをしつつ、推論された型を潰しにくいところ✨ (typescriptlang.org)
- できないことが多い!型 + runtime検証がセット🧸✨ (typescriptlang.org)
14. ちょい最新トピック(おまけ)📰✨
最近のTypeScriptは、開発体験やモジュール周りの改善も進んでるよ(例:TypeScript 5.9 のリリース)📈 (Microsoft for Developers) ※この章の主役は「ドメイン型」だけど、TypeScript自体も進化中〜!🚀
この章のまとめ🎉💖
- 外側は
string/numberでもOK、内側は “ドメイン型だけ” が最強ルール🛡️ - Brand / Value Object / Union / Template Literal Types を使い分ける🍱✨
- 境界で「検証→変換」を固定すると、SoCが保ちやすくなる🧼✅
次章(第11章)では、DTOとDomainを混ぜない「変換の設計」に突入するよ〜!📦🔁