📡

OpenAPIという間接的な型共有をやめてoRPCを導入した話

に公開

はじめに

Dress Code 株式会社のかわうそです。

今回は、フロントエンドとバックエンドの型共有に OpenAPI(コード生成)を使っていた構成から、oRPC を導入した話を紹介します。

技術スタック

この記事で登場する主な技術スタックです。

レイヤー 技術
バックエンド NestJS(TypeScript)
フロントエンド React + TanStack Router + TanStack Query
バリデーション Zod
旧 API クライアント openapi-fetch + openapi-typescript
パッケージ配布 GitHub Packages(npm)
リポジトリ構成 フロントエンド・バックエンド別リポジトリ

フロントエンドとバックエンドは別リポジトリで管理しており、型共有には npm パッケージを経由する必要があります。この構成が、後述する Contract パッケージの配布フローの背景になっています。

TL;DR

  • OpenAPI による型共有は「スキーマ定義 → コード生成 → 型の利用」という間接的な型共有だった
  • コード生成の手間、生成ファイルの Conflict 多発、開発者体験の低下が課題になっていた
  • oRPC を導入し、TypeScript の型推論による直接的な型共有に切り替えた
  • oRPC は OpenAPI 仕様書の自動生成もサポートしており、外部連携や API ドキュメントの要件も満たせる

TL;DR

OpenAPI による型共有の仕組み

Dress Code ではフロントエンド・バックエンドともに TypeScript で開発しています。

以前は、バックエンドの API 定義から OpenAPI スキーマを生成し、そのスキーマからフロントエンド用の型定義をコード生成するというワークフローで型共有を行っていました。具体的には以下の 6 段階の変換チェーンです。

① Backend: class-validator でデコレータ定義

② @nestjs/swagger が OpenAPI spec を自動生成

③ /api-json でランタイム提供

④ 開発者が手動で取得・再生成

⑤ openapi-typescript が schema.d.ts を生成

⑥ openapi-fetch + paths 型で API 呼び出し

各段階で情報劣化と同期コストが発生し、特に「④ 開発者が手動で取得・再生成」が Git Conflict の直接的な原因になっていました。生成された schema.d.ts は数万行規模のファイルで、そのまま Git コミット対象でした。

なぜ「間接的」だったのか?

振り返ってみると、Conflict は症状であって、根本原因は「OpenAPI を中継する間接的な型共有」そのもの でした。Conflict を配布方法の改善(npm パッケージ化など)で解消することは可能ですが、6 段階の変換チェーンの間接性は残ります。型共有の仕組み自体を変えないと、生産性は根本的に向上しないと考えました。

コード生成というワンステップ

バックエンドの API を変更したとき、フロントエンドで新しい型を使うためには以下のステップが必要でした。

  1. バックエンドの API 実装を変更する
  2. OpenAPI スキーマを再生成する
  3. フロントエンドのコード生成を実行する
  4. 生成された型定義が更新される
  5. フロントエンドの実装を変更する

ステップ 2〜4 が「間接的」な部分です。フロントエンドもバックエンドも TypeScript なのに、同じ言語間で型を共有するために中間成果物の生成が必要になっていました。

コード生成を忘れた状態でフロントエンドの実装を進めてしまい、CI で初めて型の不整合に気づくということも起きていました。

生成されたスキーマファイルが Conflict しやすい

OpenAPI スキーマはバックエンドの実装から生成されたファイルとしてリポジトリにコミットされます。複数人が同時に API を追加・変更していると、この生成ファイル上で Git の Conflict が頻繁に発生していました。

Conflict が起きるたびにメインブランチの最新を取り込み、スキーマを再生成し、さらにフロントエンド側の型も再生成する、という手間が都度発生します。生成ファイルの Conflict は手動で解消しづらいことも多く、結局、”再生成し直す”のが一番確実という状態でした。

TypeScript → OpenAPI → TypeScript の往復で型が劣化する

6 段階の変換チェーンでは、TypeScript の型を一度 OpenAPI のスキーマ表現に変換し、そこからまた TypeScript の型を生成します。この往復変換の過程で、TypeScript の型システムが持つ表現力(ユニオン型など)が OpenAPI の表現に収まりきらず、情報が落ちることがありました。

バックエンドで定義した型がフロントエンドで似ているけど微妙に違う型になってしまうケースがあり、型定義を補うために手動で書く場面もありました。

開発者体験の低下

フロントエンドもバックエンドも TypeScript であれば、バックエンドの型を変更した瞬間にフロントエンドでコンパイルエラーが出てほしいところです。しかし、コード生成を挟むことで以下のような開発者体験の低下がありました。

  • 型の変更がリアルタイムにフロントエンドに伝播しない
  • エディタ上での補完やエラー検出にタイムラグが生じる
  • コード生成のセットアップ・メンテナンスが必要

TypeScript 同士なのに、型を直接共有できないという課題がありました。

フロントエンドからバックエンドまで TypeScript で統一しているのだから、スキーマ定義・バリデーション・型・API クライアントまで TypeScript の型システムで完結させたいが、OpenAPI を経由するコード生成パイプラインは、その流れを途中で分断していました。

選択肢の検討

この課題を解決するために、以下の選択肢を検討しました。

要件の整理

意思決定にあたり、以下の要件を整理しました。

要件 優先度 説明
エンドツーエンドの型安全性 必須 サーバーの型変更が即座にクライアントに反映される
コード生成が不要 必須 中間成果物なしで型を共有できる
OpenAPI 仕様書の生成 必須 API ドキュメントや外部連携のために必要
コントラクトファースト開発 推奨 API の仕様を先に定義してから実装を進めるワークフロー
スキーマバリデーション 必須 Zod などのバリデーションライブラリとの統合
TanStack Query との統合 必須 フロントエンドで TanStack Query を利用しているため

検討した選択肢

以下は「TypeScript フルスタック環境で code-first + codegen パイプライン」という前提とした比較です。「コントラクトファースト」は、実装とは独立したコントラクト定義パッケージを先に作り、FE/BE がそれぞれ参照・実装するワークフローを指しています。

選択肢 型安全性 コード生成不要 OpenAPI 生成 コントラクトファースト TanStack Query 統合
OpenAPI(当時の構成) △(間接的)
tRPC ✗(別途対応が必要)
ts-rest
Nestia △(手動ラップ)
oRPC

tRPC

tRPC は TypeScript でのエンドツーエンド型安全性を広めた存在で、開発者体験も良いです。ただ、OpenAPI 仕様書の生成が標準ではサポートされておらず、API ドキュメントの生成には別途プラグインが必要でした。

ts-rest

ts-rest は REST/OpenAPI ファーストのアプローチで、型安全性と OpenAPI を両立できます。有力な選択肢でしたが、RPC スタイルに比べると記述量が多くなりがちでした。

Nestia

Nestia は NestJS の既存 Controller からクライアント SDK を自動生成する Controller-First のアプローチです。既存の NestJS コードをほぼそのまま活かせるため Backend 側の変更量が最も小さく、移行コストの低さは魅力でした。ただ、バリデーションに Typia を使う必要があり、プロジェクトで広く使っている Zod との親和性がありません。また、TanStack Query との統合は手動ラップが必要で、コントラクトファースト開発にも対応していないため、私たちの要件とは合いませんでした。

oRPC

oRPC は RPC の書き味と OpenAPI 標準への準拠を両立する TypeScript 向けフレームワークです。2025 年 12 月に v1.0 がリリースされています。

Dress Code ではフロントエンドに TanStack Router + TanStack Query を採用しています。oRPC は @orpc/tanstack-query パッケージで TanStack Query との統合を公式にサポートしており、.queryOptions.mutationOptions をそのまま useQuery / useMutation に渡せます。TanStack Router の loader 内で queryClient.ensureQueryData と組み合わせる構成とも相性が良く、ルーティングからデータフェッチまで型が一貫して繋がります。

私たちの要件をすべて満たしていたこと、PoC で実際に触ってみた感触が良かったことから、oRPC を採用しました。

oRPC で実現した構成

直接的な型共有

oRPC では、TypeScript の型推論をそのまま使って型を共有します。

TypeScript の型推論を使った型共有

コード生成もなく、中間成果物もなく、バックエンドの型変更が即座にフロントエンドに伝播します。

コントラクトファースト開発

oRPC は @orpc/contract パッケージで、コントラクトファースト開発にも対応しています。

import { oc } from "@orpc/contract";
import { z } from "zod";

const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string(),
  email: z.string().email(),
});

export const contract = oc.router({
  user: {
    find: oc.input(UserSchema.pick({ id: true })).output(UserSchema),
    create: oc.input(UserSchema.omit({ id: true })).output(UserSchema),
  },
});

このコントラクトは実装コードを一切含まず、API の型だけを定義します。フロントエンドはこのコントラクトの型だけを参照すればよく、バックエンドの実装の詳細に依存しません。

バックエンド側はこのコントラクトを実装します。

import { implement } from "@orpc/server";
import { contract } from "./contract";

const impl = implement(contract);

export const router = impl.router({
  user: {
    find: impl.user.find.handler(async ({ input }) => {
      const user = await db.user.findUnique({ where: { id: input.id } });
      return user;
    }),
    create: impl.user.create.handler(async ({ input }) => {
      const user = await db.user.create({ data: input });
      return user;
    }),
  },
});

コントラクトで定義した入出力の型と異なる実装をすると、コンパイルエラーになります。仕様と実装の一致が型レベルで保証されます。

OpenAPI 仕様書の自動生成

oRPC のルーター定義から OpenAPI 仕様書を自動生成できます。

import { OpenAPIGenerator } from "@orpc/openapi";
import { ZodToJsonSchemaConverter } from "@orpc/zod";
import { router } from "./router";

const generator = new OpenAPIGenerator({
  schemaConverters: [new ZodToJsonSchemaConverter()],
});

const spec = await generator.generate(router, {
  info: {
    title: "Dress Code API",
    version: "1.0.0",
  },
});

TypeScript の型推論で型共有しつつ、同じ定義から OpenAPI 仕様書も自動生成できます。生成された仕様書は、他言語のクライアントや外部ツールとの連携に使えます。

RPC の開発者体験を維持しつつ、OpenAPI 仕様書も出力できる点が oRPC を選んだ理由のひとつです。

NestJS との統合

Dress Code では既存のバックエンドが NestJS で構築されています。oRPC は @orpc/nest パッケージで NestJS との統合を公式にサポートしており、既存の Controller の構造を活かしたまま oRPC のコントラクトを実装できます。

import { Controller } from "@nestjs/common";
import { Implement, implement } from "@orpc/nest";
import { contract } from "@scope/api-contract";

@Controller()
export class UserController {
  @Implement(contract.user.find)
  find() {
    return implement(contract.user.find).handler(async ({ input }) => {
      const user = await this.userService.findById(input.id);
      return user;
    });
  }
}

NestJS の DI(Dependency Injection)やGuard、Interceptorといったエコシステムはそのまま使えるため、既存のコードを段階的に移行できます。ORPCModuleAppModule にインポートすれば、oRPC のルーティングと NestJS のライフサイクルが統合されます。

ネイティブ型のサポート

oRPC は DateFileBlobBigIntURL などの JavaScript ネイティブ型をアプリコード上でそのまま扱えます。ワイヤ上ではフレームワーク層がシリアライズを処理しますが、開発者が手動で文字列変換する必要はありません。

import { os } from "./server"; // implement(contract) の返り値

const getReport = os
  .input(
    z.object({
      from: z.date(),
      to: z.date(),
    }),
  )
  .output(
    z.object({
      generatedAt: z.date(),
      data: z.array(
        z.object({
          /* ... */
        }),
      ),
    }),
  )
  .handler(async ({ input }) => {
    // input.from と input.to は Date オブジェクトとして受け取れる
    return {
      generatedAt: new Date(),
      data: [],
    };
  });

Contract パッケージの運用

前述の通り、Dress Code ではフロントエンドとバックエンドを別リポジトリで管理しています。oRPC のコントラクトファースト開発では、Contract(API の型定義)をどうやって両リポジトリ間で共有するかが運用上のポイントになります。

配布フロー

Contract ファイル(*.api-contract.ts)はバックエンドリポジトリの特定ディレクトリに co-located し、ビルドスクリプトが自動収集して npm パッケージとして publish する構成にしています。

Backend リポジトリ
  src/**/*.api-contract.ts
    ↓ ビルドスクリプトが glob で自動収集
  packages/api-contract/             ← バレル index.ts を自動生成 + tsc ビルド
    ↓ CI が develop マージ時に自動 publish
  npm レジストリ(GitHub Packages)

Frontend リポジトリ
  pnpm install @scope/api-contract

  import { userContract } from "@scope/api-contract"

なお、コントラクトファーストと言いつつ Contract の所有権がバックエンドリポジトリにある点は、独立リポジトリに切り出す選択肢もありました。ただ、元々バックエンドの API 実装を起点にスキーマを生成していた経緯があり、Contract もバックエンドと密接に関わるため、同リポジトリに置くほうが管理しやすいと判断しています。

バージョニングは、破壊的変更がなければマイナーとパッチのみで運用する方針です。破壊的変更が発生した場合のメジャーバンプのフローは検討中ですが、Contract は Zod スキーマ + ルート定義だけのシンプルなパッケージなので、破壊的変更自体が起きにくい構造ではあります。

フロントエンドとバックエンドの並行開発

バックエンドと密に連携し、Contract を頻繁に修正しながら動作確認したい場合は pnpm link を利用しています。

# Backend リポジトリで Contract をビルド
cd ~/backend/packages/api-contract && npm run build

# Frontend リポジトリでリンク(publish 版の代わりにローカルビルドを参照)
cd ~/frontend
pnpm --filter app link ~/backend/packages/api-contract

# Contract を修正 → Backend で npm run build → Frontend に即反映

Contract を修正するたびに Backend 側で npm run build するだけで即座にフロントエンドに反映されるため、publish を待つ必要がありません。開発が完了したら pnpm unlink で publish 版に戻します。

シナリオ 対応方法
Contract 未確定、フロントエンド先行 ローカル Contract 定義
FE/BE 並行開発、Contract を頻繁に変更 pnpm link
Backend publish 済み、通常開発 npm パッケージ(pnpm update

モノレポ化の検討

oRPC 導入のタイミングで、フロントエンドとバックエンドのモノレポ化も合わせて検討しました。モノレポ化すれば、Contract を npm パッケージとしてビルド・配布する仕組みそのものが不要になり、型共有はさらにスムーズになります。

ただ、検証段階で影響範囲が大きくなりそうだったため、今回はモノレポ化は見送り、別リポジトリのまま oRPC を導入する方針にしました。

(一気に変えて壊れたときの切り戻しが大変なので、段階的に進めたかったというのが本音です)

oRPC の運用が安定してきたら、型共有をよりスムーズにするためにもモノレポ化は改めて検討していきたいなと思っています。

トレードオフと今後の注意点

oRPC への切り替えにはトレードオフもあります。

エコシステムの成熟度

oRPC は 2025 年 12 月に v1.0 がリリースされたばかりで、tRPC や OpenAPI ツールチェーンと比べるとコミュニティやサードパーティの周辺ツールはまだ少ないです。困ったときに参考にできる事例や記事が限られる点は、考慮が必要になります。

非 TypeScript クライアントへの展開

今の構成はフロントエンド・バックエンドが同じ TypeScript だからこそ直接的な型共有の恩恵を受けられています。今後、非 TypeScript のクライアントが増えた場合は、oRPC から OpenAPI スキーマを生成するか、GraphQL や gRPC といった別のプロトコルを採用するかの判断が必要になります。

導入してみてハマったところ

oRPC 自体の導入はスムーズでしたが、既存の環境との組み合わせでいくつかハマりました。

ESM と CJS の運用が衝突した

@orpc/contract は ESM-only のパッケージです。ところが、私たちの Backend には ts-node(CJS モード)で動いているスクリプトがあり、NestJS Module の依存グラフを辿って @orpc/contractrequire() しようとしてしまい、MODULE_NOT_FOUND で落ちるケースがありました。

一時的なワークアラウンドで凌いでいますが、根本的には CJS に依存しているスクリプトを ESM に寄せていく方針で進めています。

Zod のバージョン違いでファイルアップロードが困った

Backend は Zod v4 に移行済みですが、Frontend はまだ v3 のままです。共有の Contract パッケージは Frontend に合わせて Zod v3 でビルドしているので、z.file() が使えません。oRPC でファイルアップロードの multipart/form-data 自動送信を動かすには oz.file() が必要なので、ファイル系の Contract だけ Frontend と Backend でそれぞれ別に定義するという少し不格好な構成になっています。

Frontend の Zod v4 移行が終われば解消するので、oRPC の本格展開と合わせて優先的に進めていこうと思っています。

まとめ

OpenAPI による型共有は「スキーマ → コード生成 → 型」という間接的なアプローチでした。フロントエンドもバックエンドも TypeScript という環境では、このワンステップが開発者体験のボトルネックになっていたなと感じています。

oRPC に切り替えたことで、TypeScript の型推論による直接的な型共有と、OpenAPI 仕様書の自動生成を両立できるようになりました。

まだまだ導入したばかりなので、使ってみて発生した課題や改善点があったらまた記事にしていきたいと思います。

DRESS CODE TECH BLOG

Discussion