UPSIDER Tech Blog

契約を「ドキュメント」から「コード」へ。TypeSpec で複数プロダクトの型安全をつなぐ ── TSKaigi 2026 登壇レポート

こんにちは。UPSIDER でエンジニアリングマネージャーをしている mitsui です。

2026 年 5 月 22 日(金)に開催された TSKaigi 2026 にて、「TypeSpec で築く複数プロダクトの型安全」というテーマで登壇しました。

TSKaigi は TypeScript をテーマにしたカンファレンスです。言語そのものの活用ノウハウから、それを支えるエコシステムまで、TypeScript にまつわる実践知が集まる場です。

この記事では、当日の発表内容に加えて、時間の都合で話しきれなかった補足も交えながら、UPSIDER での TypeSpec 活用について紹介します。

発表の背景

今回の発表テーマは、「TypeSpec を起点に、複数プロダクト・複数チームのあいだで型安全な契約をどう守るか」です。

プロダクト内であれば、コード生成によってフロントエンド・バックエンド間の型を揃え、tsc などでズレをビルド時に検出できます。

一方、私たちの当時の運用では、サービス間の契約を TypeScript の型として継続的に取り込む仕組みが十分ではなく、tsc の守備範囲の外になっていました。

実際に、Webhook ペイロードの型を Slack で確認したり、サンプル JSON から型を推測してコードを書いたりする場面がありました。

そこで私たちは、TypeSpec を使ってサービス間の契約を「ドキュメント」ではなく「コード」として扱う運用に切り替えました。

同じように、複数プロダクト・複数サービスをまたぐ型安全に悩んでいる方に、少しでも参考になればと思っています。

TypeSpec とは

TypeSpec は、Microsoft が OSS として公開している API・データ契約の定義言語です。

書き味は TypeScript に近く、model / op / @decorator といった構文で API やデータ構造を記述できます。1 つの .tsp ファイルから OpenAPI、JSON Schema、Protocol Buffers などの形式に変換できます。

@service(#{ title: "Pet Store" })
namespace PetStore;

model Pet {
  id: string;
  name: string;
}

@route("/pets")
op listPets(): Pet[];

TypeSpec の強みの 1 つは、バリデーションルールも契約定義に含められることです。

正規表現、長さ制限などのルールを型定義と同じ場所に書けます。tsp compile 経由で OpenAPI に出力し、さらに orval などで Zod スキーマに変換すれば、コンパイル時の型チェックだけでなく、実行時バリデーションにも活用できます。

@pattern("^[a-z0-9]{8,}$")
scalar UserId extends string;

model User {
  id: UserId;

  @minLength(8)
  password: string;

  @minValue(0)
  @maxValue(120)
  age?: int32;
}

拡張:Custom Linter で設計ルールもガードレールにする

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 }),
  }),
});

LSP 統合によってエディタ上でも warning が表示されるため、CI を待たずに気づける点も便利です。

私たちは現状、CI 上の外部スクリプトで同等のチェックを回していますが、将来的には Custom Linter に寄せることで、tsp compile の流れにより自然に統合できると考えています。

契約の書き方だけでなく、設計ルールまでガードレールにできる点は、TypeSpec の大きな魅力の 1 つです。

私たちの使い方 1:プロダクト内での型共有

私たちのチームでは、TypeScript フルスタックのプロダクトを複数運用しています。その中で、TypeSpec を起点にした次のようなパイプラインを使っています。

.tsp  →  tsp compile  →  OpenAPI YAML
                              ├─ openapi-typescript ─→ TypeScript 型
                              └─ orval ──────────────→ Zod / API クライアント / MSW モック

生成物は、フロントエンド・バックエンド・テストの各領域で利用しています。

  • フロントエンド(React)
    • フォームバリデーション
    • API 呼び出し
    • テストモック
  • バックエンド(Hono)
    • リクエストバリデーション
    • レスポンス型

たとえば「パスワードは 8 文字以上」というルールを TypeSpec に 1 行書けば、そのルールをフロントエンド・バックエンド・テストに伝達できます。

ここまでは、TypeScript フルスタックにおける型共有の話として、それほど珍しくないかもしれません。

私たちにとって TypeSpec が特に効いてきたのは、この先のサービス間の領域でした。

私たちの使い方 2:サービス間の契約管理

「契約が型として存在しない」問題

プロダクト内の型共有はある程度解けていても、チームやサービスをまたいだ瞬間に難易度が上がります。

たとえば、次のような状態です。

  • Slack で「Webhook のペイロードって何型ですか?」と聞き合う
  • Notion を見ても、3 ヶ月前から更新が止まっている
  • サンプル JSON から型を推測してコードを書く

この状態では、人手で契約を守るしかありません。コンパイラによるフィードバックも効きづらく、契約の変更に気づくタイミングが遅れがちになります。

同期 API も Webhook イベントも、同じ TypeSpec に書く

このような課題に対して、私たちは契約をできるだけコードベースに落とし込むことにしました。

TypeSpec では、同期 API だけでなく、Webhook イベントのようなデータ契約も同じ .tsp ファイルに記述できます。

// 同期 API
@route("/applications")
op listApplications(): Application[];

// Webhook イベント
model EntityApprovedEvent {
  type: "entity.approved";
  data: ApprovalData;
}

同期 API と Webhook イベントでは粒度や使われ方は異なります。

それでも、どちらもサービス間で共有される「契約」です。TypeSpec 上に同居させることで、API も Webhook も、同じ契約定義の流れで管理できます。

これにより、サービス間で「API は OpenAPI、Webhook は別のドキュメントやサンプル JSON」といった形で分散していた契約を、より統合的に扱えるようになりました。

ドキュメントも契約と一緒に管理する

OpenAPI YAML を直接管理していると、型定義と説明文が別々の場所で更新されることがあります。その結果、型は新しいのに説明文が古い、あるいはドキュメントだけ更新されて実装側に反映されていない、といったズレが起きやすくなります。

TypeSpec では、doc comment を書くことで、生成される OpenAPI YAML の description フィールドに説明を反映できます。

/** 法人ユーザー(管理者を含む) */
model User {
  /** 一意 ID(8 文字以上) */
  id: UserId;

  /** 連絡先メールアドレス */
  email: string;
}

型と説明を同じソースで管理できるため、片方だけが古くなる状態を避けやすくなります。

npm パッケージとして契約を配る

私たちの運用では、上流のチームが TypeSpec で契約を書き、それをビルドして npm パッケージとして公開します。

{
  "dependencies": {
    "@org/contracts": "^1.0.0"
  }
}
import { EntityApprovedEvent } from "@org/contracts";

EntityApprovedEvent.parse(payload);

これにより、契約は Notion だけで参照するものではなく、package.json の依存としてコードベースに存在する状態になります。

契約変更の影響範囲を PR 上で検知する

この仕組みの大きな効果は、契約変更の影響に早く気づけることです。

たとえば、上流のチームが型を変更し、新しい major version を publish したとします。

すると、Renovate や Dependabot などの依存更新 bot が自動で PR を作成します。その PR の CI で tsc が走り、契約と一致しない箇所が型エラーとして検知されます。

つまり、誰かが手元で気づくのを待つのではなく、PR チェックが赤くなることで、契約変更の影響をレビュー前に把握できます。

もちろん、これだけで契約違反を完全に防げるわけではありません。実行時の互換性、リリース順序、後方互換性の設計など、ほかにも考えるべきことはあります。

それでも、サービス境界の変更を型エラーとして早い段階で検知できることは、複数プロダクト・複数チームで開発するうえで大きな安心材料になります。

Go 中心のチームでも TypeSpec で契約を書ける

TypeSpec は、TypeScript チーム専用のツールではありません。

私たちの上流には Go を中心に開発しているチームもありますが、そのチームでも契約定義には TypeSpec を採用しています。

理由は大きく 3 つあります。

  1. TypeScript ライクな書き味で、契約 DSL としての学習コストが比較的低い
  2. OpenAPI などに変換できるため、Go の codegen 系ワークフローともつなげやすい
  3. 上流の Go チームと下流の TypeScript チームが、同じ TypeSpec ファイルを共通言語として扱える

契約を書く言語と、サーバーの実装言語は独立して選べます。

これは、複数の技術スタックを抱える組織にとって大きな利点です。

発表のポイント

今回の発表では、主に次の 3 点をお伝えしました。

1. API も Webhook も TypeSpec に書ける

同期 API、Webhook イベント、バリデーションルール、設計ルールを、同じ TypeSpec の流れで扱えます。

2. 書いた契約は npm で配れて、破壊的変更は CI で検知できる

サービス境界の契約を package.json の依存として取り込み、契約変更の影響をコンパイルタイムに検知できます。

3. TypeScript 以外の実装言語でも、契約は TypeSpec で書ける

契約 DSL とサーバーの実装言語は独立して選べます。異なる技術スタックを持つチーム同士でも、TypeSpec を共通言語として扱えます。

一言でまとめるなら、今回伝えたかったのは「契約はドキュメントではなくコード、そしてガードレールへ」ということです。

スキーマ駆動の開発を、もう一段厳格に運用へ落とし込む。そのための選択肢として、TypeSpec はかなり有力だと感じています。

発表資料

当日の発表資料はこちらです。

speakerdeck.com

おわりに

TSKaigi 2026 では、自分の発表だけでなく、他のセッションや懇親会を通じても多くの刺激を受けました。

TypeScript エコシステムの広がりや、各社が型安全の実現にどう向き合っているのかを直接聞ける場は、とても貴重でした。

今回紹介した「サービス境界を越える型契約」の運用は、私たちのチームでもまだ進化の途中です。

同じように、複数プロダクト・複数チームをまたぐ型契約に悩んでいる方がいれば、ぜひお話ししましょう。

We’re hiring!

UPSIDER では、現在エンジニアを積極採用しています。

複数プロダクト・複数サービスをまたぐ型安全な開発体験を、一緒につくっていきたい方は、ぜひお気軽にご応募ください。

herp.careers

herp.careers

UPSIDER Engineering Deckはこちら📣

speakerdeck.com