Slide 1

Slide 1 text

TypeScript の型で副作用の実行順序を制御する 白栁 広樹 / Hiroki Shirayanagi TSKaigi 2026

Slide 2

Slide 2 text

自己紹介 About me 白栁 広樹 / Hiroki Shirayanagi 株式会社ミツモア / MeetsMore inc. プロダクト本部長 2013 年 : ヤフー 2018 年 : ミツモア Full-Stack TypeScript でプロダクト作っています! @yanaemon @yanaemon169

Slide 3

Slide 3 text

今日のスコープ Scope 今日話すこと / 話さないこと 今日話すこと 副作用を持つ関数の実行順序を “型”で 制御する方法 関連ライブラリとの簡単な比較 今日話さないこと 副作用の合成・モデル化 (例. Effect-TS) 関連ライブラリの詳細な内容 (例. Effect-TS / XState / etc…)

Slide 4

Slide 4 text

TypeScript は 副作用 をどこまで表現できるか 問題提起 Problem 副作用 = 関数が入出力以外で、外部の状態を取得したり変更したりする処理 (例. DB 書込 / 通知 / I/O / console.log 等) TS の型で表現できる / できない ◎ 引数 / 返り値の形 // ◎ 引数 / 返り値 — 副作用のない純粋関数なら型で十分 function add(a: number, b: number): number { ... } // ◯ async / Promise — 非同期は伝わる async function fetchUser(id: string): Promise; // ⚠ Promise — 結果なし、何が起きるか(副作用)は不明 // 「何の副作用が起きるか (予定)」「何が起きたか (事実)」 // → どちらも型に出ない async function saveUser(u: User): Promise; ◯ 非同期かどうか / 失敗の可能性 ⚠ 副作用が起こる予定・起きた事実

Slide 5

Slide 5 text

業務処理は 副作用の連鎖 — 順序ミスしてもコンパイルを通る 問題提起 Problem validate → save → notify の順番を間違えると runtime バグ. 気付くのは実行時エラーで // 📝 よくある副作用を伴う処理(ユーザー作成) type UserData = { name: string, age: number } const userData = { name: 'ミツモア', age: 30 } class UserService { validate(input: UserData): boolean { ... } // Pure? async save(input: UserData): Promise { ... } // 副作用 (DB書 込) async notify(input: UserData): Promise { ... } // 副作用 (通 知) } const userService = new UserService() // ❌ save を忘れて notify を実行 userService.notify(userData) // ❌ validate を忘れて save を実行 userService.save(userData) 連鎖する DB → 通知 → 外部 API → 集計更新 失敗しうる 各ステップでネットワーク / DB エラー 順序が命 間違えると不正データ / 存在しない ユーザーへの通知 など 業務での副作用の特徴 副作用の「順序」を 型で守りたい!

Slide 6

Slide 6 text

副作用・状態遷移ライブラリ Libraries 副作用や状態遷移を扱う ライブラリ 副作用モデル系 (例. Effect-TS) 副作用を Effect で型化して合成するアプローチ 副作用 / エラー型 / 依存性 / リソース を型で扱う flatMap で順序を 定義 できる 副作用の表現や実行順の定義はできるが、 型で縛るには追加実装が必要 状態機械系 (例. XState) 状態遷移を runtime actor で管理するアプローチ 状態 / 並列 / 階層 / 可視化 を扱える runtime actor が不正遷移を 無視 する ★本トークのゴール 「型だけで」業務順序を強制する方法はないか 順序強制は基本的には runtime 型で縛るには追加実装が必要 2

Slide 7

Slide 7 text

解法 ① Phantom Type 基本方針: 値の型に「処理済み」ラベルを貼る (Phantom Type) ライブラリ無し・TS 標準の型だけで考えてみる 順序ミスはコンパイル時に止まる ランタイムコスト 0 / ライブラリ不要 ポイント 値の型に「validate を通った」ラベルを 貼って引数の型を限定 → 順序ミスをコンパイル時に止める // 💡 Point 1. Phantom Type をデータに付与 . コンパイル時には消える type ValidatedUserData = UserData & { readonly _state: "validated" }; type SavedUserData = ValidatedUserData & { readonly _state: "saved" }; type NotifiedUserData = ValidatedUserData & { readonly _state: "notified" }; class UserService { validate(input: UserData): ValidatedUserData { ... } // 💡 Point 2. 引数と返り値を state 付きの型にすることで順序を強制 async save(input: ValidatedUserData): Promise { ... } async notify(input: SavedUserData): Promise { ... } } const userService = new UserService() // 🚨 Argument of type '...' is not assignable to ... 'SavedUserData'. userService.notify(userData) // 🚨 Argument of type '{ ... }' is not assignable to ... 'ValidatedUserData'. userService.save(userData) // 無事コンパイルエラーに 🎉 解決 ⁉

Slide 8

Slide 8 text

ちょっと待って Step Back ラベル付け ≠ 状態管理 やったこと 値の型に状態ラベルを乗せた 状態が 引数・返り値の型 に乗っている → Class instance 自体は無状態、値が状態を持ち歩く userData as ValidatedUserData を出来てしまう...😱 限界 本質 「進行中のプロセス」「副作用が走ったかどうか」は状態 → 状態 × 振る舞いを「箱」に集めるのが本来形 → クラスの型パラメータに状態を持たせれば?

Slide 9

Slide 9 text

解法 ② (本命) Type-State Pattern クラスの型パラメータに状態を持たせる ポイント instance = 進行中のプロセス 状態 + 振る舞いを 1 つの箱に集約 状態 = 世界へのコミット "saved" = DB に行が存在する事実 Promise reject = 遷移しない save 失敗 → saved instance なし → 通知は型で呼べない class UserService { // 💡 Point 1. State を型として保持する phantom field // declare で、コンパイル後に型は消える! declare private readonly _state: State; private constructor(private readonly data: UserData) {} validate(this: UserService<"draft">): UserService<"validated"> | null { ... return new UserService<"validated">(this.data) } // 💡 Point 2. 引数と返り値を state 付きの型にすることで順序を強制 async save(this: UserService<"validated">): Promise> { ... return new UserService<"saved">(this.data) } ... }

Slide 10

Slide 10 text

Type-State Pattern Demo 動かしてみる // ✅ 正しい順序 (await で次の状態を unwrap) const userService = new UserService(userData) const validated = userService.validate() if (validated) { const saved = await validated.save() // 副作用 (DB 書込) await saved.notify() // 副作用 (通知送信 ) } // ❌ 型エラー // 🚨 ... 'UserService<"draft">' is not assignable to ... 'UserService<"validated">'. await userService.save() // 🚨 ... 'UserService<"validated">' is not assignable to ... 'UserService<"saved">'. await userService.validate()!.notify() Type-State の威力 IDE 補完が絞られる 「今呼べるメソッドだけ」を提示 型エラーで止まる validate を飛ばして save → コンパイル不可 状態の型を構造化すれば表現力 UP { _state: “validated” | “saved” | …} → { validated: boolean; saved: boolean; … } 複雑度が上がると型が大量になり 読みにくくなる可能性 副作用が大きい重要な部分に限定すると良い 例. 金銭・セキュリティ関連、外部境界など

Slide 11

Slide 11 text

なぜ効くか Why It Works 副作用の事実 を型で記録する 副作用の「予定」に加えて、Type-State は副作用の「事実」を記録 // 🕐 副作用の「予定」を型で記録 save(d: Data): Effect> { ... } // 「DB に書く予定」「失敗しうる」 ↑ ↑ // 「Database 依存が必要」 ↑ // ✅ Type-State: 副作用の「事実」を型で記録 async function save(this: UserService<"validated">): Promise> { await db.users.insert(this.data); return new UserService<"saved">(this.data); } // ↑ 「DB に書いた事実」を型タグで記録 // 次の notify は <"saved"> を要求できる 3 つの本質 instance = 進行中のプロセス 状態 + 振る舞いを 1 つの箱に集約 状態 = 世界へのコミット "saved" = DB に行が存在する事実 失敗時は次の状態が作られない 副作用 reject → saved instance 不在 → 後続メソッドは型レベルで呼べない 補完関係 — 予定 + 事実 (Type-State) で完全に 8 ©ミツモア

Slide 12

Slide 12 text

実行モデル Eager vs Lazy 実行のタイミング — 即時実行 (eager) vs 遅延実行 (lazy) 今までは eager での実行例. lazy パターンで、副作用を持つ処理を「値」として組み立てると、全体を把握できる // Eager: 呼んだ瞬間に副作用が走る const validated = service.validate(); const saved = await validated.save(); // ← この場で DB 書込 await saved.notify(); // ← この場で通知送信 // Lazy: pipeline を 型に積み上げる const program = Program.start>() .add({ type: "validate", fn: (u) => u.validate()! }) .add({ type: "save", fn: (u) => u.save() }) .add({ type: "notify", fn: (u) => u.notify() }); // ↑ hover で 全 step の I/O 型 (pipeline 全体) が見える // Program<[ // US = UserService // ["validate", US<"draft">, US<"validated">], // ["save", US<"validated">, US<"saved">], // ["notify", US<"saved">, US<"notified">], // ]> await program.run(initial); // ← ここで初めて実行 Lazy + 型積み上げ pipeline 全体が型に出る hover で各 step の I/O 観測 実行前に検査できる 構造から validation / dry-run ドメインと分離可 pipeline 構築自体は汎用的な処理 再利用・合成 pipeline を値として渡せる Type-State は遅延実行も可(構築時に型エラー). 本格運用なら ライブラリに任せる のが現実的

Slide 13

Slide 13 text

拡張 Extension 拡張方法は様々 — 場面に応じて使い分け Type-State Pattern は 1 つの基本形。状態を型で扱うパターンは多数 1 State 型を豊かに { _state: “validated” | “saved” | … } / { validated: boolean; saved: boolean; … } union で積み上げ / 構造化 / メタデータ保持 — ドメインに応じて拡張 2 Type Predicate / Assertion isValidated(): this is UserService<"validated"> / assertValidated(): asserts this is ... runtime check に基づいて型を絞り込む 3 Narrowing (別 Interface や Omit などで制限) type Draft = Omit 1 interface + Omit で API を絞る / 補完にも出ない 4 Dispatcher Pattern dispatch(state, { type: "VALIDATE" }) 判別 union + dispatch / JSON 化可 / Redux 系 9 他にも色々...

Slide 14

Slide 14 text

位置づけ Where It Fits 観点 Phantom Type Type-State 副作用モデル系 (ex. Effect-TS) 状態管理系 (ex. XState) 状態の在り処 値の型 クラスの型 値の型 (合成入出力) runtime インスタンス 順序の強制 型 型 △ 単独不可 (要 Phantom/Type-State) runtime が基本 +α 機能 — — エラー型 / 並行 / cancel 並列 / 階層 / 可視化 ライブラリ / runtime 不要 不要 必要 (重) ※ Effect-TS の場合 FP 前提 必要 (中) 適性ドメイン 値ラベル 簡易な順序 副作用モデル全体 複雑遷移 / UI 状態 → 順序強制ができる 3 つの中で、Type-State Pattern は依存ゼロ — 副作用モデル系は組み合わせれば補完 既存ライブラリとの比較

Slide 15

Slide 15 text

設計判断 Checklist どれを選ぶか — 上から順に当てはまるものを選ぶ 1 複雑な状態遷移 (parallel / hierarchy / history) が必要 → 状態機械系 (例: XState) 2 副作用モデル全体 (エラー型 / 並行 / cancel) を型で扱いたい ※ Effect-TS の場合 FP 前提 → 副作用モデル系 (例: Effect-TS) 3 値中心の軽量ラベル (識別子の区別など) → Phantom Type ★ 上記以外 (= 副作用順序を 型だけで 守りたい) → Type-State Pattern Type-State Pattern + 副作用モデル系を組み合わせるとさらに強固に Type-State で順序、Effect でエラー型・並行・cancel

Slide 16

Slide 16 text

まとめ Conclusion 値のラベル から、 副作用の実行順 を縛る型 へ 1 Phanttom Type / Type-State Pattern で副作用の「事実」を型で記録できる 2 予定 + 事実 は両方を型で表すことで、副作用全体を型で守れる 3 既存のライブラリと組み合わせることでより強力に 例. 実行順の制御 & 強制 + 並行・キャンセル・エラーハンドリング・etc…

Slide 17

Slide 17 text

Thank you! We are hiring! https://corp.meetsmore.com/ ミツモア採用ページ https://hrmos.co/pages/meetsmore 求人一覧