Slide 1

Slide 1 text

© LayerX Inc. 柔軟なPDFレイアウトエディタを⽀える型システム設計 — Discriminated UnionとConditional Typeの実践 TSKaigi2026 @minako-ph

Slide 2

Slide 2 text

⽬次 Agenda ● わたしと会社について ● デモ + 課題提⽰ ● アーキテクチャ全体像 ● データモデル:Discriminated Union ● Proto oneof → Zod → TS型の独⾃パイプライン ● Conditional Type とDeepPartial の実践 ● エディタとPDFを同じ型で駆動 ● 設計から得た知⾒‧まとめ ● APPENDIX

Slide 3

Slide 3 text

© LayerX Inc. 3 ● 2023年8⽉ 株式会社LayerX ⼊社 ○ バクラク事業部 ソフトウェアエンジニア ● 型しか勝たん ⼭本美奈⼦(minako-ph) ⾃⼰紹介 ● ex - ○ 株式会社メディカルフォース ■ リードエンジニア ○ DMM.comグループ 株式会社終活ねっと ■ フロントエンドエンジニア

Slide 4

Slide 4 text

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月末時点)

Slide 5

Slide 5 text

5 © LayerX Inc. 「すべての経済活動を、デジタル化する。」をミッションに、複合的な事業を通して⽇本の社会 課題を解決し、AIの⼒で⼈々の創造⼒がより発揮される未来をつくります。 事業紹介 バクラク事業 バックオフィス向け AIエージェントサービスを提供 Fintech事業 資産運⽤サービス 「ALTERNA(オルタナ)」を提供 Ai Workforce事業 エンタープライズ向け AIプラットフォームを提供 Security事業 AIエージェントによる ⾃律的なペネトレーションテストを提供

Slide 6

Slide 6 text

© LayerX Inc. 6 事業紹介 バックオフィスに必要なあらゆる業務をカバーし、AIエージェントのサポートを通じて最適なソリューションを提供しています。 業務に応じた最適なソリューション

Slide 7

Slide 7 text

デモ+課題提⽰

Slide 8

Slide 8 text

© LayerX Inc. 8 デモ+課題提⽰ バクラク請求書発⾏ のレイアウトエディタ これを作っています Figma に似た操作感で、請求書 PDF をユー ザーが⾃由にデザイン 「請求書」「⾒積書」「納品書」など書類種別 ごとにテンプレートを保存 エディタのプレビューと最終 PDF が⼀致 ● ● ● Figmaに似た操作感 豊富なデータバインド 柔軟な表⽰条件

Slide 9

Slide 9 text

© LayerX Inc. 9 デモ+課題提⽰ なぜ複雑か 6種類のノード型 3種類のデータソース 表⽰条件のAND/OR⽊ ヘッダ∕本⽂∕フッタ 複数ページ グループ化 70以上のバインド可能 フィールド Undo / Redo これらを掛け合わせた組み合わせを、1つのJSONで表現する必要がある ⾃由度をUIで提供するということは、データモデル上は無限の組み合わせを許容すること。

Slide 10

Slide 10 text

© LayerX Inc. 10 デモ+課題提⽰ 型がなければ何が起きるか 本セッションの問い:型システムだけで、これらを構造的に防ぐにはどう設計するか? ! テキストノードの値型を扱う処理が、テーブルノードの列定義型に「うっかり」アクセス ! 新しいノード型を追加したのに、どこを修正すべきか型が教えてくれない ! エディタのプレビューと最終PDFが⾷い違う ! コードレビューでこれを⼈間が全部追うのは現実的でない

Slide 11

Slide 11 text

アーキテクチャ全体像 Proto → Zod → TS → React → PDF

Slide 12

Slide 12 text

© 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 useResolveNodeContents JSONより堅牢

Slide 13

Slide 13 text

© 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 から型⽣成できる嬉しさ

Slide 14

Slide 14 text

データモデル:Discriminated Union ありえない状態を型で禁⽌する

Slide 15

Slide 15 text

© LayerX Inc. 15 データモデル:Discriminated Union レイアウト全体は1つのJSON タプル型 [Page, ...Page[]] で「先頭ページは必ず存在する」を表現

Slide 16

Slide 16 text

© 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 にしかないフィールドへの誤アクセス = コンパイルエラー で弾ける

Slide 17

Slide 17 text

© LayerX Inc. 17 データモデル:Discriminated Union 「ありえない状態」を型で禁⽌ ❌ ナイーブな設計(OOP的) → 「type === "text" なのに imageUrl を⾒てしまう」がランタイムエ ラー ✅ Discriminated Union → 不正アクセスは型チェックで弾かれる。レビュー要らず。 状態遷移の不正をデータ表現の段階で禁⽌する。バリデーションの前に「書けない」。

Slide 18

Slide 18 text

© LayerX Inc. 18 データモデル:Discriminated Union exhaustive check で「漏れ」を検知 コードベースでの使⽤箇所 58 箇所で _exhaustiveCheck: never パターンが使われている Proto に oneof の case を1つ追加すると default 節すべてがコンパイルエラーになり、 修正必要箇所が型から判明する

Slide 19

Slide 19 text

Proto oneof → Zod → TS型の独⾃パイプライン 本セッションの技術的ハイライト

Slide 20

Slide 20 text

© LayerX Inc. 20 データモデル:Discriminated Union Proto の oneof を⾒直す oneof は「複数フィールドのうち1つだけ値を持つ」を表現する仕組み そのまま TS に変換すると case discriminator 付き union になる(Buf の TS変換) これを Zod の z.discriminatedUnion("case", [...]) に対応させたい ● ● ●

Slide 21

Slide 21 text

© 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 がそのままランタイムバリデーションに変換される

Slide 22

Slide 22 text

© LayerX Inc. 22 データモデル:Discriminated Union oneof 変換の実装抜粋 proto の oneof を必ず discriminator 付き union に変換。required かどうかも proto の制約から⾃動判定。

Slide 23

Slide 23 text

© LayerX Inc. 23 データモデル:Discriminated Union 問題:ConditionGroup.conditions は Condition[] 、Condition.expression の case "group" の中⾝が ConditionGroup ⾃⾝ 再帰型の難所と解決策 「base + extend + 型注釈」の3点セット:ランタイムは正しく動かしつつ、型は明⽰で与える

Slide 24

Slide 24 text

© LayerX Inc. 24 データモデル:Discriminated Union dev/gen-layout-data-types.ts — TypeScript コンパイラ API で template_zod.ts を解析 layoutDataTypes.ts — TS-AST で型をエクスポート proto を書き換え make gen 全60+型が⾃動更新 → →

Slide 25

Slide 25 text

Conditional Type とDeepPartial の実践 ⽣成された型をどう使いやすくするか

Slide 26

Slide 26 text

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

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

© LayerX Inc. 30 Conditional Type とDeepPartial の実践 DistributiveOmit / Override 現状 utils/types.ts に定義はあるが、レイアウトエディタの本体コードでは現在 実利⽤されていない。 標準 Omit の「全 variant の共通キーしか除外できない」問題への解として⽤意されているが、現状は出番がない。 「将来必要になったときのため」のユーティリティ。

Slide 31

Slide 31 text

エディタとPDFを同じ型で駆動 同じ型を異なる⽤途で使う

Slide 32

Slide 32 text

© LayerX Inc. 32 エディタとPDFを同じ型で駆動 NodeContents — 描画⽤の中間表現 ● nodeId をキー 描画コンテンツを値とする Record ● null = 「表⽰条件で⾮表⽰」 undefined ではなく null で明⽰ ● groupedNodes は再帰 ネスト構造をそのまま型に反映

Slide 33

Slide 33 text

© LayerX Inc. 33 エディタとPDFを同じ型で駆動 解決関数を1つに統⼀

Slide 34

Slide 34 text

© LayerX Inc. 34 エディタとPDFを同じ型で駆動 エディタと PDF は「同じ関数の異なる⼊⼒」 エディタプレビュー PDF⽣成 エディタで⾒ている画⾯が、PDF出⼒と⼀致する保証を「型と関数の共有」で実現。 PDF は Puppeteer で /documentPdf?p=... を印刷することで⽣成(HTMLとPDFが同じ実装)。

Slide 35

Slide 35 text

© 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 で本番データに対し検証可能。

Slide 36

Slide 36 text

設計から得た知⾒‧まとめ 原則の抽出と Take-aways

Slide 37

Slide 37 text

© LayerX Inc. 37 設計から得た知⾒‧まとめ 設計から抽出した8つの原則 1 Proto を Single Source of Truth ⽣成された Zod‧TS 型は⼿書きしない 2 Discriminated Union で不正状態排除 case discriminator + variant で必要なフィールドだけを持つ 3 Conditional Type で安全に型を取り出す Extract["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

Slide 38

Slide 38 text

© LayerX Inc. 38 設計から得た知⾒‧まとめ 困った点(正直に) タプル型 [Page, ...Page[]] の取り回し pages.map(...) の返り値は Page[] になるため as キャストが8箇所のReducerに散らばってしまっている 再帰型 + Zod の相性 @ts-expect-error を貼らざるを得ない箇所が出る Proto enum と Zod refine の組み合わせ 型推論が崩壊しがち DistributiveOmit / Override は使い所を⾒出せず未活⽤ 「将来のため」のユーティリティになっている

Slide 39

Slide 39 text

© LayerX Inc. 39 設計から得た知⾒‧まとめ 「⾃由度の⾼い UI」は ユーザー体験 としては喜ばれるが、 コードベース では複雑さを連れてくる。 しかしその複雑さを「ランタイム検査」ではなく「型」で潰せるなら、 レビューや実装のコストは劇的に下がる。 Take-aways 3つの組み合わせで、6 × 3 × ∞通りの組み合わせを安全に扱える oneof → discriminatedUnion の⾃動変換 Conditional Type による安全な型派⽣ DeepPartial による部分更新

Slide 40

Slide 40 text

© LayerX Inc. Thank you. ご清聴ありがとうございました。

Slide 41

Slide 41 text

時間が余ったら‧質疑⽤ APPENDIX

Slide 42

Slide 42 text

© LayerX Inc. 42 APPENDIX Appendix A — データバインディングの3種 jsonFieldNameByDefaultDataType テーブル(90以上のエントリ)で DefaultFieldType → JSONパスをマッピング

Slide 43

Slide 43 text

© 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 しか使えない」を表現

Slide 44

Slide 44 text

© LayerX Inc. 44 APPENDIX Appendix C — Undo / Redo ● 配列+インデックスでスナップショット管理 ● appendHistory currentHistoryIdx + 1 以降を破棄して新スナップショット追加 ● nodeLocationMap useReducer ラッパー withNodeLocationMap で全アクション後に再構築

Slide 45

Slide 45 text

© LayerX Inc. 45 APPENDIX Appendix D — グループ化ノードの相対座標計算 models/GroupedNode.ts の updateGroupedNodeRect Figma のグループ化リサイズと同じアルゴリズム

Slide 46

Slide 46 text

© 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 で分岐できる、追加が楽。