Slide 1

Slide 1 text

TypeSpec で繋ぐ複数プロダクトの型安全 スキーマ共有による「型契約」の実践

Slide 2

Slide 2 text

TypeSpec って何? Microsoft 製の OSS — API ・データ契約の定義言語 書き味は TypeScript ライク (model / op / @decorator) 1 つの .tsp → OpenAPI / JSON Schema / Protobuf に変換 @service(#{ title: "Pet Store" }) namespace PetStore; model Pet { id: string; name: string; } @route("/pets") op listPets(): Pet[]; TypeSpec とは

Slide 3

Slide 3 text

バリデーションも、型に焼き込む TypeSpec は単なる型定義だけじゃない。 正規表現 / 長さ制限 / 値域 といったバリデーションを 型と一体で書ける。 @pattern("^[a-z0-9]{8,}$") scalar UserId extends string; model User { id: UserId; @minLength(8) password: string; @minValue(0) @maxValue(120) age?: int32; } この制約は OpenAPI 経由で Zod にも変換でき、実行時バリデーション としても効く バリデーション

Slide 4

Slide 4 text

設計ルールも、tsp compile で縛れる TypeSpec の Custom Linter で、組織固有の設計ルールを tsp compile 時に強制できる。 例: 「string scalar には @pattern 必須」をルール化、違反は warning 。 createRule({ name: "require-pattern", severity: "warning", create: (ctx) => ({ scalar: (s) => isStringScalar(s) && !hasPattern(s) && ctx.reportDiagnostic({ target: s }), }), }); bad.tsp:4:8 - warning scalar 'UserId' は string を継承していますが、 @pattern が指定されていません。 > scalar UserId extends string; ^^^^^^ Found 1 warning. 契約の 書き方 だけでなく 設計ルール までガードレールに TypeSpec の拡張性

Slide 5

Slide 5 text

私たちの使い方 ① プロダクト内 .tsp → tsp compile → OpenAPI YAML ├─ openapi-typescript ─→ TS 型 └─ orval ──────────────→ Zod / API クライアント / MSW モック フロント (React): フォーム validation / API 呼び出し / テストモック バックエンド (Hono): リクエスト validation / レスポンス型 「パスワード 8 文字以上」を TypeSpec で 1 行書けば、FE ・BE ・テスト全部に伝播 使い方 ①

Slide 6

Slide 6 text

チーム間の型契約 Section

Slide 7

Slide 7 text

サービス間では、契約が型として存在しない # dev-cross-team サービス A エンジニア 3 分前 @team Webhook のペイロード、これって 何型ですか? サービス B エンジニア 2 分前 Notion 見たけど 3 ヶ月前から更新止まってる… サービス C エンジニア 1 分前 サンプル JSON から 型を推測しました… 人手で守るしかなく、コンパイラの出番がない サービス間の課題

Slide 8

Slide 8 text

同期 API も 非同期 Webhook も 同じ言語で書ける 同期 API は OpenAPI 、非同期メッセージは AsyncAPI / 独自スキーマ、と 別の言語で書くのが普通。 TypeSpec なら 1 つの .tsp に 両方の契約を同居 できる。 // 同期 API(@route + op で表現) @route("/applications") op listApplications(): Application[]; // 非同期 Webhook イベント(model で純粋なデータ契約として表現) model EntityApprovedEvent { type: "entity.approved"; data: ApprovalData; } どちらも 同じ TypeSpec ソースから生成される契約として扱える 独自ポイント

Slide 9

Slide 9 text

ドキュメントも、契約と一緒に書ける OpenAPI YAML 管理だと 型と説明文が別場所 で並走しがち。 TypeSpec なら /** ... */ で 型と同じ場所に 書け、OpenAPI に自動反映される。 /** 法人ユーザー(管理者を含む) */ model User { /** 一意 ID (8 文字以上) */ id: UserId; /** 連絡先メールアドレス */ email: string; } User: type: object description: 法人ユーザー(管理者を含む) properties: id: description: 一意 ID (8 文字以上) email: description: 連絡先メールアドレス 型とドキュメントが 同じソース で管理されるから、説明が古くなりにくい 管理の co-location

Slide 10

Slide 10 text

私たちの使い方 ② サービス間 上流チームが TypeSpec で書いた契約は npm パッケージとして公開される。 使う側のプロダクトは npm install + import するだけで取り込める。 上流チーム TypeSpec で契約定義 → ビルド → npm publish → npm レジストリ 📦 @org/contracts 型 / Zod / API クライアント → 使う側のプロダクト npm install + import 別チーム / 別サービス "dependencies": { "@org/contracts": "^1.0.0" } import { EntityApprovedEvent } from '@org/contracts' EntityApprovedEvent.parse(payload) サービス境界を跨ぐ契約を、ドキュメントから「型」へ 使い方 ②

Slide 11

Slide 11 text

サービス跨ぐ契約変更を、マージ前に検知する 上流が破壊的変更を含む新 version を publish すると、Renovate / Dependabot 等の依存更新 bot が自動で PR を起票する。 CI checks ✓ lint ✗ typecheck error TS2339: 'oldField' does not exist on 'NewEvent' ❌ Merge blocked マージ前に、契約変更の影響範囲が型エラーとして列挙される 効果

Slide 12

Slide 12 text

Go 中心のチームでも、TypeSpec で契約を書ける とあるチームの実装は Go ベース。 それでも契約定義は TypeSpec を採用 している。 書き味は TypeScript ライク Go メイン実装でも、契約 DSL の学習コスト は低い 出力は実装言語に依存しない OpenAPI に変換すれば Go の codegen にも 繋がる 複数チームの共通言語 上流 (Go) と下流 (TS) が同じ TypeSpec を見 て契約を共有 契約を書く言語と、サーバーの実装言語は独立して選べる 拡がり

Slide 13

Slide 13 text

今日の話を 3 行でまとめると 1 API も Webhook も、TypeSpec 1 ファイル で書ける 同期 API ・非同期イベント・バリデーション・設計ルールまで、同じ DSL に集約 2 書いた契約は npm で配れて、破壊的変更は CI で落ちる サービス境界の契約が package.json の依存として存在し、影響範囲はコンパイルタイムに列挙される 3 TypeScript 以外 の実装言語でも、契約は TypeSpec で書ける 契約 DSL と、サーバーの実装言語は独立して選べる — 異なる技術スタックの共通言語になる 契約は ドキュメント ではなく コード、そして ガードレール へ 総括

Slide 14

Slide 14 text

スキーマ駆動を、もう一段厳格に。