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

柔軟なPDFレイアウトエディタを支える型システム設計 — Discriminated Unio...

柔軟なPDFレイアウトエディタを支える型システム設計 — Discriminated UnionとConditional Typeの実践

バクラク請求書発行では、請求書・見積書・納品書などのPDFレイアウトをGUIで自由にデザインできるビジュアルエディタを提供しています。テキスト・画像・動的テーブルなど6種類のノードを自由に配置し、実際の書類データをバインドしてPDFを生成する仕組みです。

しかし自由度が高いほど、コードの複雑さも増します。テキストノードにはフォント設定があり、テーブルノードには列定義があり、画像ノードにはフィット方法がある——同じ「ノード」でも中身はまったく別物です。さらにノードの値は固定テキストかもしれないし、書類データから動的に取得するかもしれない。こうした組み合わせの中で「テキストノードなのにテーブルの列定義を参照してしまう」ような取り違えをどう防ぐか。人のレビューに頼るのではなく、型に任せられないか—— 本トークはその問いから始まります。

Protocol Buffersのoneof定義からZod経由でDiscriminated Unionを自動生成する仕組み、Conditional Typeでノード型名から値型を安全に取り出すパターン、DeepPartialでReducerの部分更新を型安全にする設計、そして同一の型定義でエディタのプレビューとPDF本番生成の両方を駆動するアーキテクチャを、具体的なコードとともに紹介します。

Avatar for minako-ph

minako-ph

May 23, 2026

More Decks by minako-ph

Other Decks in Programming

Transcript

  1. ⽬次 Agenda • わたしと会社について • デモ + 課題提⽰ • アーキテクチャ全体像

    • データモデル:Discriminated Union • Proto oneof → Zod → TS型の独⾃パイプライン • Conditional Type とDeepPartial の実践 • エディタとPDFを同じ型で駆動 • 設計から得た知⾒‧まとめ • APPENDIX
  2. © LayerX Inc. 3 • 2023年8⽉ 株式会社LayerX ⼊社 ◦ バクラク事業部

    ソフトウェアエンジニア • 型しか勝たん ⼭本美奈⼦(minako-ph) ⾃⼰紹介 • ex - ◦ 株式会社メディカルフォース ▪ リードエンジニア ◦ DMM.comグループ 株式会社終活ねっと ▪ フロントエンドエンジニア
  3. 4 © LayerX Inc. すべての経済活動を、デジタル化する。 会社概要 会社名 株式会社LayerX(レイヤーエックス) 代表取締役 代表取締役CEO 福島

    良典 代表取締役CTO 松本 勇気 創業 2018年 8⽉1⽇ 資本⾦ 282.6億円(準備⾦含む) 拠点 東京本社 〒104-0045 東京都中央区築地1-13-1 銀座松⽵スクエア 5階 関⻄⽀社 〒530-0002 ⼤阪府⼤阪市北区曽根崎新地1-13-22 御堂筋フロントタワー 内 中部⽀社 〒466-0064 愛知県名古屋市昭和区鶴舞1-2-32 STATION Ai 内 九州⽀社 〒810-0801 福岡県福岡市博多区中洲3-7-24 WeWorkゲイツ福岡 11F 内 従業員数 646名 (2026年3月末時点)
  4. 5 © LayerX Inc. 「すべての経済活動を、デジタル化する。」をミッションに、複合的な事業を通して⽇本の社会 課題を解決し、AIの⼒で⼈々の創造⼒がより発揮される未来をつくります。 事業紹介 バクラク事業 バックオフィス向け AIエージェントサービスを提供

    Fintech事業 資産運⽤サービス 「ALTERNA(オルタナ)」を提供 Ai Workforce事業 エンタープライズ向け AIプラットフォームを提供 Security事業 AIエージェントによる ⾃律的なペネトレーションテストを提供
  5. © LayerX Inc. 8 デモ+課題提⽰ バクラク請求書発⾏ のレイアウトエディタ これを作っています Figma に似た操作感で、請求書

    PDF をユー ザーが⾃由にデザイン 「請求書」「⾒積書」「納品書」など書類種別 ごとにテンプレートを保存 エディタのプレビューと最終 PDF が⼀致 • • • Figmaに似た操作感 豊富なデータバインド 柔軟な表⽰条件
  6. © LayerX Inc. 9 デモ+課題提⽰ なぜ複雑か 6種類のノード型 3種類のデータソース 表⽰条件のAND/OR⽊ ヘッダ∕本⽂∕フッタ

    複数ページ グループ化 70以上のバインド可能 フィールド Undo / Redo これらを掛け合わせた組み合わせを、1つのJSONで表現する必要がある ⾃由度をUIで提供するということは、データモデル上は無限の組み合わせを許容すること。
  7. © LayerX Inc. 10 デモ+課題提⽰ 型がなければ何が起きるか 本セッションの問い:型システムだけで、これらを構造的に防ぐにはどう設計するか? ! テキストノードの値型を扱う処理が、テーブルノードの列定義型に「うっかり」アクセス !

    新しいノード型を追加したのに、どこを修正すべきか型が教えてくれない ! エディタのプレビューと最終PDFが⾷い違う ! コードレビューでこれを⼈間が全部追うのは現実的でない
  8. © LayerX Inc. 12 アーキテクチャ全体像 4層の型の連鎖 1 Protocol Buffers template.proto

    ↓ 独⾃プラグイン protoc-gen-zod 2 Zod スキーマ template_zod.ts ↓ TS-AST 解析 gen-layout-data-types.ts 3 TypeScript 型 layoutDataTypes.ts ↓ models/Layout.ts で薄くラップ 4 React + Reducer useEditorState / DocumentLayoutViewer ↓ Proto round-trip で永続化 5 DB base64-encoded protobuf binary message DocumentLayoutData { ... oneof node { ... } ... } z.discriminatedUnion("case", [...]) / z.lazy(() => ...) export type Node = z.infer<typeof DocumentLayoutData_Node$Schema> useResolveNodeContents JSONより堅牢
  9. © LayerX Inc. 13 アーキテクチャ全体像 単⼀の真実の源 — Single Source of

    Truth 1 すべての型は Proto から派⽣する レイアウトエディタは拡張が頻発するデカい機能。 拡張頻発でも互換性が壊れにくい(Single Source of Truth の核) 2 バックエンドGoとフロントエンドTSで共有 同じスキーマから両⾔語の型を⽣成。サーバ‧クライアントの境界を型で繋ぐ 3 API は Protobuf binary を base64 した⽂字列で返す JSON より圧倒的にコンパクト + Node.js のシングルスレッドCPU負荷を回避 4 バリデーションは保存時に Zod で実⾏ gateway は素通し、フロント側で Zod parse。Go 側も同 Proto から型⽣成できる嬉しさ
  10. © LayerX Inc. 16 データモデル:Discriminated Union Node 型 — 6

    variant の Discriminated Union • case フィールド = discriminator (判別⼦) narrow if (case === "text") の中で TextNode に型が narrow さ れる 6 variantはまったく異なる構造を持つ TextNode/DataListTable/Image …で必要なフィールド が違う • • • 誤アクセス = コンパイルエラー 別variantのフィールドに触れない ある variant にしかないフィールドへの誤アクセス = コンパイルエラー で弾ける
  11. © LayerX Inc. 17 データモデル:Discriminated Union 「ありえない状態」を型で禁⽌ ❌ ナイーブな設計(OOP的) →

    「type === "text" なのに imageUrl を⾒てしまう」がランタイムエ ラー ✅ Discriminated Union → 不正アクセスは型チェックで弾かれる。レビュー要らず。 状態遷移の不正をデータ表現の段階で禁⽌する。バリデーションの前に「書けない」。
  12. © LayerX Inc. 18 データモデル:Discriminated Union exhaustive check で「漏れ」を検知 コードベースでの使⽤箇所

    58 箇所で _exhaustiveCheck: never パターンが使われている Proto に oneof の case を1つ追加すると default 節すべてがコンパイルエラーになり、 修正必要箇所が型から判明する
  13. © LayerX Inc. 20 データモデル:Discriminated Union Proto の oneof を⾒直す

    oneof は「複数フィールドのうち1つだけ値を持つ」を表現する仕組み そのまま TS に変換すると case discriminator 付き union になる(Buf の TS変換) これを Zod の z.discriminatedUnion("case", [...]) に対応させたい • • •
  14. © LayerX Inc. 21 データモデル:Discriminated Union dev/protoc-gen-zod/index.ts — @bufbuild/protoplugin で書かれた独⾃プラグイン

    独⾃ protoc プラグインの変換ルール Proto → ⽣成される Zod oneof z.discriminatedUnion("case", [...]) string z.string() string [(buf.validate.field).string.min_len = 1] z.string().min(1) uint32 z.number().int().nonnegative() uint32 [(buf.validate.field).uint32 = {gte:0,lte:255}] z.number()...gte(0).lte(255) enum [(buf.validate.field).enum = {not_in:[0]}] z.nativeEnum(E).refine(...) repeated Foo z.lazy(() => Foo$Schema.array()) google.protobuf.UInt32Value z.number()...optional() buf.validate の constraint がそのままランタイムバリデーションに変換される
  15. © LayerX Inc. 22 データモデル:Discriminated Union oneof 変換の実装抜粋 proto の

    oneof を必ず discriminator 付き union に変換。required かどうかも proto の制約から⾃動判定。
  16. © LayerX Inc. 23 データモデル:Discriminated Union 問題:ConditionGroup.conditions は Condition[] 、Condition.expression

    の case "group" の中⾝が ConditionGroup ⾃⾝ 再帰型の難所と解決策 「base + extend + 型注釈」の3点セット:ランタイムは正しく動かしつつ、型は明⽰で与える
  17. © LayerX Inc. 24 データモデル:Discriminated Union dev/gen-layout-data-types.ts — TypeScript コンパイラ

    API で template_zod.ts を解析 layoutDataTypes.ts — TS-AST で型をエクスポート proto を書き換え make gen 全60+型が⾃動更新 → →
  18. © LayerX Inc. 26 Conditional Type とDeepPartial の実践 NodeValue<T> —

    Conditional Type で値型を取り出す 3つの型レベル操作の組み合わせ 1 Indexed Access Node["node"]["case"] 2 Extract<U, S> Union から特定 variant 抽出 3 ["value"] value 部分だけ取り出し
  19. © LayerX Inc. 27 Conditional Type とDeepPartial の実践 DeepPartial<T> —

    部分更新の表現 Partial<T> ではダメな理由 • Partial<NodeRect> だと1階層しか緩まない • 「NodeStyles の中の borderTop の中の color の中 の red だけ」を更新したい • → 再帰的な Partial が必要 Reducer の payload 実体化は deepmerge
  20. © LayerX Inc. 28 Conditional Type とDeepPartial の実践 Reducer Action

    型のカテゴリ分割 3階層命名規則: type: "category:subcategory:operation" 例: textNode:styles:update, node:rect:update:batch
  21. © LayerX Inc. 29 Conditional Type とDeepPartial の実践 DeepPartial と

    Reducer の合体 Omit<DeepPartial<T>, "id"> という⼩技で「IDだけは触らせない部分更新」を表現。 proto の generated 型に対しても完璧に効く。
  22. © LayerX Inc. 30 Conditional Type とDeepPartial の実践 DistributiveOmit /

    Override 現状 utils/types.ts に定義はあるが、レイアウトエディタの本体コードでは現在 実利⽤されていない。 標準 Omit<Union, K> の「全 variant の共通キーしか除外できない」問題への解として⽤意されているが、現状は出番がない。 「将来必要になったときのため」のユーティリティ。
  23. © LayerX Inc. 32 エディタとPDFを同じ型で駆動 NodeContents — 描画⽤の中間表現 • nodeId

    をキー 描画コンテンツを値とする Record • null = 「表⽰条件で⾮表⽰」 undefined ではなく null で明⽰ • groupedNodes は再帰 ネスト構造をそのまま型に反映
  24. © LayerX Inc. 34 エディタとPDFを同じ型で駆動 エディタと PDF は「同じ関数の異なる⼊⼒」 エディタプレビュー PDF⽣成

    エディタで⾒ている画⾯が、PDF出⼒と⼀致する保証を「型と関数の共有」で実現。 PDF は Puppeteer で /documentPdf?p=... を印刷することで⽣成(HTMLとPDFが同じ実装)。
  25. © LayerX Inc. 35 エディタとPDFを同じ型で駆動 Proto round-trip(永続化) 保存:TS → Proto

    → Binary → Base64 → DB 読み込み:DB → Base64 → Binary → Proto → Zod parse → TS 古いレイアウトデータも Zod parse。後⽅互換性は dev/check-layout-json-compatibility.ts で本番データに対し検証可能。
  26. © LayerX Inc. 37 設計から得た知⾒‧まとめ 設計から抽出した8つの原則 1 Proto を Single

    Source of Truth ⽣成された Zod‧TS 型は⼿書きしない 2 Discriminated Union で不正状態排除 case discriminator + variant で必要なフィールドだけを持つ 3 Conditional Type で安全に型を取り出す Extract<U, { case: T }>["value"] 4 DeepPartial で型安全な部分更新 Reducer の payload で「変更箇所だけ」 5 Template Literal Type でルーティング ${prefix}:${string} で type predicate 6 exhaustive check で漏れを機械的に検知 _exhaustiveCheck: never を default に(52箇所) 7 エディタとPDFで同じ関数を共有 ⼊⼒の違いだけで⽤途を切り替える 8 readonly + Immutableで整合性保持 InternalEditorState の全プロパティが readonly
  27. © LayerX Inc. 38 設計から得た知⾒‧まとめ 困った点(正直に) タプル型 [Page, ...Page[]] の取り回し

    pages.map(...) の返り値は Page[] になるため as キャストが8箇所のReducerに散らばってしまっている 再帰型 + Zod の相性 @ts-expect-error を貼らざるを得ない箇所が出る Proto enum と Zod refine の組み合わせ 型推論が崩壊しがち DistributiveOmit / Override は使い所を⾒出せず未活⽤ 「将来のため」のユーティリティになっている
  28. © LayerX Inc. 39 設計から得た知⾒‧まとめ 「⾃由度の⾼い UI」は ユーザー体験 としては喜ばれるが、 コードベース

    では複雑さを連れてくる。 しかしその複雑さを「ランタイム検査」ではなく「型」で潰せるなら、 レビューや実装のコストは劇的に下がる。 Take-aways 3つの組み合わせで、6 × 3 × ∞通りの組み合わせを安全に扱える oneof → discriminatedUnion の⾃動変換 Conditional Type による安全な型派⽣ DeepPartial による部分更新
  29. © LayerX Inc. 42 APPENDIX Appendix A — データバインディングの3種 jsonFieldNameByDefaultDataType

    テーブル(90以上のエントリ)で DefaultFieldType → JSONパスをマッピング
  30. © LayerX Inc. 43 APPENDIX Appendix B — 表⽰条件の再帰⽊ データ型ごとに使える

    operator が違う Number: eq / ne / gt / gtEq / lt / ltEq / empty / notEmpty String: eq / ne / startsWith / endsWith / contains / notContains / empty / notEmpty Enum: eq / ne のみ Date: empty / notEmpty のみ proto の oneof で「型が許す operator しか使えない」を表現
  31. © LayerX Inc. 44 APPENDIX Appendix C — Undo /

    Redo • 配列+インデックスでスナップショット管理 • appendHistory currentHistoryIdx + 1 以降を破棄して新スナップショット追加 • nodeLocationMap useReducer ラッパー withNodeLocationMap で全アクション後に再構築
  32. © LayerX Inc. 45 APPENDIX Appendix D — グループ化ノードの相対座標計算 models/GroupedNode.ts

    の updateGroupedNodeRect Figma のグループ化リサイズと同じアルゴリズム
  33. © LayerX Inc. 46 APPENDIX Q&A Q. なぜ Zod? TypeBox

    や Valibot ではない? A. 当時の選択。Buf のプラグインで⽣成しやすく、ランタイムバリデーションも兼ねたい。 Q. 後⽅互換性は? A. proto の規約(フィールド削除禁⽌、番号予約)に加えて、本番データに対して safeParse するスクリプトがある。 Q. パフォーマンスは? A. useResolveNodeContents は useMemo でメモ化。partialUpdate は deepmerge の都度オブジェクト⽣成。 Q. テストは? A. evaluateConditionGroup.test.ts、resolveDataListTableNodeContents.test.ts など、型安全な fixture でテーブル駆動テスト。 Q. なぜ ${prefix}:${string} で⽂字列キー?enum じゃない? A. logger 出⼒が読みやすい、Reducer ルーティング時に prefix で分岐できる、追加が楽。