Upgrade to Pro — share decks privately, control downloads, hide ads and more …

フロントエンドにおける「型」の責任分解に対する1つのアプローチ

Avatar for kinocoboy kinocoboy
November 16, 2025

 フロントエンドにおける「型」の責任分解に対する1つのアプローチ

フロントエンド開発で、UIの状態を示すStateと、ビジネスロジックの核となるDomain Modelの型を混同していませんか?

例えばレストランの注文。入力フォームにおける「数量」は、未入力時の空文字も許容するstring | number型。しかしAPIへ送信するDomain Modelにおける「数量」は厳密にnumber型でなければなりません。

この2つを同一視すると、UIの都合でDomain Modelの型定義が汚染され、不要な型ガードがコンポーネントに散乱します。本発表では、StateとDomain Modelの間に明確な境界を引き、両者を変換する層を設けることで関心事を分離。

そもそも実務において、この境界線を見つけることがまず難易度が高い問題があります。
本セッションでは「型の境界線」の見つけ方を具体例とともにお話しします。

Avatar for kinocoboy

kinocoboy

November 16, 2025
Tweet

More Decks by kinocoboy

Other Decks in Business

Transcript

  1. 自己紹介 © kaonavi, inc. 2 kinocoboy | キノシタ ヒロキ 所属:

    株式会社カオナビ 趣味: プラモ / ギター プライベート : 2児の父 / 北海道出身・千葉在住 好きだったライブラリ : Riot.js , Meteor.js (わかる人いますか...?)
  2. © kaonavi, inc. 🤔 設計の迷い • UIの要求とAPIの要求が違う • バリデーション処理があちこちに散ら ばる

    • どっちに合わせるべき? >フロントエンドの型定義、どうやって整理したらいいの? 5 よくある悩み 🤔 😰 現実のコードベース • string | number だらけの型定 義 • フォームからAPIまで同じ型を使い回 し • この型、どこで使うんだっけ? 問題提起:なぜフロントエンドでは型定義がこんなに複雑になるのか?
  3. © kaonavi, inc. Clean Architectureとは ビジネスルールを外部の詳細(UI、DB、フレー ムワーク)から独立させる設計原則 7 Clean Architecture

    を知ってみる 核心原則 依存性は内側に向かって流れる(外側は内 側に依存するが、内側は外側に依存しな い)
  4. © kaonavi, inc. 9 フロントエンドで「教科書通り」が難しい理由 🎨 フロントエンドでの Clean Architecture 外側からの要求も強い(トップダウン圧力)

    • UI(ユーザー体験)が重要 • API(システム要求)も重要 • 両方に最適化が必要 🏗 バックエンドでの Clean Architecture 内側から外側へ設計(ボトムアップ) • Entity(ビジネスルール)が中心 • Domain Model がすべての基準 • DB スキーマも Entity に従う 課題:フロントエンドは「ユーザーの都合」と「システムの都合」の 2つの外部からの圧力を受ける
  5. © kaonavi, inc. 11 結論:「ダブルスタンダード」の存在 核心 フロントエンドは「ユーザー(UI)」と「システム(API/DB)」という 2つの異なるプリミティブな要望を繋ぐ場所 だから。 📋今日話すこと:

    1. レストランの例え話 で見る「ダブルスタンダード」 2. 「行間」に潜む UI State の正体 3. 設計概念 と TypeScript で作る「境界線」の実装
  6. © kaonavi, inc. 🤤 腹ペコ (User) お金を払って、 料理(体験)を得たい 13 🍽レストランの登場人物

    󰞽 シェフ (System) 料理を提供して、 対価を得たい 重要な関係性:ユーザーとシステムはお互いが顧客である ⚡
  7. © kaonavi, inc. 関心事:効率、特定、計算 「オムレツ」という文字より id: 1 が欲しい 14 󰞽システム(シェフ)が見ている世界

    interface Request { foodId: 1, // ID で特定 count: 1 // 数値で計算 } 🔍 特徴 • 暗黙知が多く、無駄がない • Strict なデータ構造 • 効率性とパフォーマンスを重視
  8. © kaonavi, inc. 関心事:不安解消、選択、理解 id: 1 と言われても分からない。 「オムレツ」「1000円」「美味しそうな写真」が必要 15 🤤

    ユーザー(腹ぺこ)が見ている世界 interface Food { id: number, name: "オムレツ", yen: 1000, description: "ふわふわです" } 🎨 特徴 • システムが知っている情報(価格など)も含める • 冗長でも親切な提示が必要 • ユーザーの不安と迷いを解消
  9. © kaonavi, inc. 🎨 UI の都合 ユーザーは入力を間違えるし、一時的に空にする 16 ⚡摩擦の発生源「入力フォーム」 🖥

    Domain の都合 システムは正確な数値が欲しい 失敗パターン:この 2つを混ぜて 「Partial<Order>」 のような型をコンポーネントから APIクライア ントまで引き回してしまう ⚔ 具体例:注文時の「個数」入力 quantity: string | number // 空文字許容、全角数字対応など quantity: number
  10. © kaonavi, inc. 17 🕳行間にあるもの = UI State 気づき: システムが必要とする情報(

    Request)と、ユーザーが必要とする情報( View Model)の間には「行間」がある 🎭行間の正体: • 変換待ちの状態: 入力中の曖昧なデータ(バリデーション前) • 状況の状態: 「料理待ち」「お釣り待ち」「注文送信中」 type UserContext = | { status: 'IDLE' } | { status: 'ORDERING', form: OrderFormState } | { status: 'WAITING_FOOD', order: OrderPayload } | { status: 'EATING' };
  11. © kaonavi, inc. Robustness Diagram による境界の発見 19 🏗ICONIX プロセスで整理する Boundary

    (画面) ユーザーに優しく String許容、リッチな情報 Control (ロジック) 変換する場所 曖昧さ → 厳密さ Entity (ドメイン) システムに厳しく Strict、正規化 この問題をユースケース駆動 で捉え直す UI Component 状態 Controller 変換層 Usecase ビジネス Domain Model 純粋な型 🔑
  12. © kaonavi, inc. 21 🎯型定義の分離 ❌Bad: 1つの型を使い回す ✅Good: 明確に分ける interface

    Order { // Domain が汚染されている count: number | string; } // UI State (User の都合) interface OrderFormState = { // 空文字 OK countInput: string; selectedMenuId: number | null; }; // Domain Model (System の都合) interface OrderPayload = { foodId: number; count: number; };
  13. © kaonavi, inc. 22 🔄変換ロジック( Mapper)の実装 「行間」を埋める関数を作る。ここでバリデーションと型変換を行う。 const toPayload =

    (state: OrderFormState): OrderPayload => { const count = parseInt(state.countInput, 10); if (isNaN(count)) throw new Error("数量を入れてね"); if (!state.selectedMenuId) throw new Error("メニューを選んでね"); return { foodId: state.selectedMenuId, count: count }; }; 🎉 メリット UI の変更(デザイン変更) とDomain の変更(API 変更)が互いに影響しなくなる
  14. © kaonavi, inc. // UI State (フォームの状態) interface OrderFormState {

    // UI都合: 空文字許容 countInput: string; selectedMenuId: number | null; // UI都合: ローディング状態 isSubmitting: boolean; } // Domain Model (API通信用) interface OrderPayload { // System都合: 厳密な型 foodId: number; count: number; } Reduxによる実装 24 🏪状態管理による関心ごと分離の実装   // Action で変換を明示   const submitOrder = createAsyncThunk(    'order/submit',    async (formState: OrderFormState) => {    // 中間層での変換処理    const payload =       convertFormToPayload(formState);    return await api.submitOrder(payload);    }   );
  15. © kaonavi, inc. // UI State (フォームの状態) interface OrderFormState {

    // UI都合: 空文字許容 countInput: string; selectedMenuId: number | null; // UI都合: ローディング状態 isSubmitting: boolean; } // Domain Model (API通信用) interface OrderPayload { // System都合: 厳密な型 foodId: number; count: number; } Reduxによる実装 25 🏪状態管理による関心ごと分離の実装   // Action で変換を明示   const submitOrder = createAsyncThunk(    'order/submit',    async (formState: OrderFormState) => {    // 中間層での変換処理    const payload =       convertFormToPayload(formState);    return await api.submitOrder(payload);    }   ); Boundary Entity Control
  16. © kaonavi, inc. // UI State atoms (フォームの状態 ) const

    countInputAtom = atom(''); const selectedMenuIdAtom = atom(null); const isSubmittingAtom = atom(false); // Derived atom で変換処理を分離 const orderPayloadAtom = atom((get) => { const countInput = get(countInputAtom); const menuId = get(selectedMenuIdAtom); // バリデーションと変換をここで実施 if (!menuId || !countInput) return null; const count = parseInt(countInput, 10); if (isNaN(count)) return null; return { foodId: menuId, count }; }); Jotaiによる実装 26 🏪状態管理による関心ごと分離の実装 // API連携は別のatomで管理 const submitOrderAtom = atom(null, async (get, set) => { const payload = get(orderPayloadAtom); if (!payload) throw new Error('Invalid form'); set(isSubmittingAtom, true); const result = await api.submitOrder(payload); set(isSubmittingAtom, false); return result; });
  17. © kaonavi, inc. // UI State atoms (フォームの状態 ) const

    countInputAtom = atom(''); const selectedMenuIdAtom = atom(null); const isSubmittingAtom = atom(false); // Derived atom で変換処理を分離 const orderPayloadAtom = atom((get) => { const countInput = get(countInputAtom); const menuId = get(selectedMenuIdAtom); // バリデーションと変換をここで実施 if (!menuId || !countInput) return null; const count = parseInt(countInput, 10); if (isNaN(count)) return null; return { foodId: menuId, count }; }); Jotaiによる実装 27 🏪状態管理による関心ごと分離の実装 // API連携は別のatomで管理 const submitOrderAtom = atom(null, async (get, set) => { const payload = get(orderPayloadAtom); if (!payload) throw new Error('Invalid form'); set(isSubmittingAtom, true); const result = await api.submitOrder(payload); set(isSubmittingAtom, false); return result; }); Boundary Entity Controll Control
  18. © kaonavi, inc. 29 🎭状態(Status)の型定義 データだけでなく「状況」も型にする type UserContext = |

    { status: 'IDLE' } | { status: 'ORDERING', form: OrderFormState } | { status: 'WAITING_FOOD', order: OrderPayload } | { status: 'EATING' }; // 使用例 const handleUserAction = (context: UserContext) => { switch (context.status) { case 'ORDERING': // ここでは form にアクセス可能 return validateForm(context.form); case 'WAITING_FOOD': // ここでは order にアクセス可能 return trackOrder(context.order); // ... } }; 効果:タグ付き Union型を使うことで、今の状態であり得ないデータを物理的にアク セス不可にする
  19. © kaonavi, inc. 30 📝今日のまとめ フロントエンドは「ユーザーの都合」と「システムの都合」の交差点である 1. これを混同すると設計が破綻する(ダブルスタンダードの無視) 2. UI

    State(曖昧)とDomain Model(厳密)の間に明確な境界線(変換層)を引こう 3. 「行間」を埋めるのがフロントエンドエンジニアの腕の見せ所 😋 UI State ユーザーフレンドリー 󰳏 Domain Model システムフレンドリー 🤵
  20. © kaonavi, inc. 31 🚀Next Action 💡アクションプラン 1. 既存の型定義を「UI用」「API用」に分類 2.

    Adapter層(変換関数)を1つ作ってみる 3. Tagged Union型で状態管理を改善 それは「UI State」と「Domain Model」が混ざっているサインかもしれません まずは型を2つに分けることから始めましょう 皆さんのコードベースを見直してみてください • string | number や Optional だらけの型 • コンポーネントからAPIクライアントまで同じ型を使い回している コード • フォームのバリデーションがあちこちに散らばっている コード