フロントエンド・バックエンド間の型の齟齬に苦労していた話
フリーランスで複数のSaaS開発に携わるようになって気づいたのが、フロントエンド(Next.js/TypeScript)とバックエンド(Go)を分離するとき、ほぼ100%の確率で型定義がズレるということです。APIのレスポンスフォーマットが変わったのに、フロント側でまだ古い型を使っていたり、その逆もしかり。小さなプロジェクトなら手作業でも追える範囲ですが、チーム規模が大きくなったり、スピードを求められるときは、型の二重管理が開発の足を引っ張ります。
Claude Codeを使って、OpenAPI仕様を中心に据えることで、フロント・バックの型定義を一元管理する方法を見つけました。本記事では、その実装パターンを紹介します。
フルスタック開発の型管理問題を図解する
まず、問題の構図を整理してみましょう。
- 従来のアプローチ:バックエンドがAPIを作る → フロントエンドがそれに合わせて型を書く → 変更があるたびに両方修正
- OpenAPI中心のアプローチ:OpenAPI仕様を先に定義 → フロント・バックの型をそこから自動生成
後者のほうが確実に手戻りが減ります。
環境:Next.js 14 + Go 1.22 + OpenAPI 3.1
本記事で使用するバージョンは以下の通りです。
- Next.js: 14.0以上(App Router前提)
- TypeScript: 5.3以上
- Go: 1.22以上
- OpenAPI: 3.1.0
- 生成ツール:oapi-codegen(Go側)、openapi-typescript(フロント側)
ステップ1:OpenAPI仕様を設計する
すべての始まりはOpenAPI仕様です。APIの契約書として、フロント・バック両者が合意する仕様を作ります。
シンプルなブログAPI(記事一覧取得・作成)を例に、openapi.yamlを書いてみましょう。
openapi: 3.1.0
info:
title: Blog API
version: 1.0.0
servers:
- url: http://localhost:8080
description: Development
- url: https://api.example.com
description: Production
paths:
/posts:
get:
summary: Get all posts
operationId: getPosts
parameters:
- name: limit
in: query
schema:
type: integer
default: 10
responses:
'200':
description: Success
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Post'
post:
summary: Create a post
operationId: createPost
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreatePostRequest'
responses:
'201':
description: Created
content:
application/json:
schema:
$ref: '#/components/schemas/Post'
components:
schemas:
Post:
type: object
required:
- id
- title
- body
- createdAt
properties:
id:
type: string
format: uuid
title:
type: string
maxLength: 200
body:
type: string
createdAt:
type: string
format: date-time
CreatePostRequest:
type: object
required:
- title
- body
properties:
title:
type: string
maxLength: 200
body:
type: string
このYAMLファイルを、フロント・バック両者の「真実の源」として扱います。
ステップ2:Go側で型を自動生成する
oapi-codegenを使ってGoの型定義とサーバーのインターフェースを生成します。
まずoapi-codegenをインストール:
go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest
次に、.cfg.yamlで生成ルールを定義します:
output: api/generated.go
package: api
generate:
types: true
chi-server: true
models: true
import-mapping:
time.Time: time.Time
そして生成コマンドを実行:
oapi-codegen -config .oapi.cfg.yaml openapi.yaml > api/generated.go
生成されたGoコード(一部抜粋):
type Post struct {
Body string `json:"body"`
CreatedAt time.Time `json:"createdAt"`
Id string `json:"id"`
Title string `json:"title"`
}
type CreatePostRequest struct {
Body string `json:"body"`
Title string `json:"title"`
}
type ServerInterface interface {
GetPosts(w http.ResponseWriter, r *http.Request, params GetPostsParams)
CreatePost(w http.ResponseWriter, r *http.Request)
}
生成されたインターフェースを実装すれば、型安全なハンドラーが書けます:
type BlogHandler struct {
db *sql.DB
}
func (h *BlogHandler) GetPosts(w http.ResponseWriter, r *http.Request, params api.GetPostsParams) {
limit := 10
if params.Limit != nil {
limit = *params.Limit
}
// limit の型は自動的に int と判定される
posts, err := h.fetchPosts(r.Context(), limit)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(posts)
}
func (h *BlogHandler) CreatePost(w http.ResponseWriter, r *http.Request) {
var req api.CreatePostRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// req.Title, req.Body は型チェック済み
post := api.Post{
Id: uuid.New().String(),
Title: req.Title,
Body: req.Body,
CreatedAt: time.Now(),
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(post)
}
重要なのは、GetPostsParamsやCreatePostRequestの構造が自動生成されるため、OpenAPI仕様を変更すれば、そのミスマッチがコンパイル時にGoの型チェックで検出されるということです。
ステップ3:Next.js側で型を自動生成する
openapi-typescriptを使ってフロント側の型を生成します。
インストール:
npm install --save-dev openapi-typescript
生成コマンド:
openapi-typescript openapi.yaml -o ./types/api.ts
生成されたTypeScript型(一部抜粋):
export interface paths {
"/posts": {
get: {
parameters: {
query?: {
limit?: number;
};
};
responses: {
200: {
content: {
"application/json": Post[];
};
};
};
};
post: {
requestBody: {
content: {
"application/json": CreatePostRequest;
};
};
responses: {
201: {
content: {
"application/json": Post;
};
};
};
};
};
}
export interface components {
schemas: {
Post: {
id: string;
title: string;
body: string;
createdAt: string; // date-time format
};
CreatePostRequest: {
title: string;
body: string;
};
};
}
これらの型を使ってAPIクライアントを書くと、フロント側もコンパイル時に不正な呼び出しが検出されます。
ステップ4:フロント側でAPI呼び出しを型安全に実装
生成された型を使ったAPIクライアント関数を作ります。公式ドキュメントで推奨されているfetch-openapiライブラリを使う方法もありますが、ここでは手書きの例を示します。
import { components } from '@/types/api';
type Post = components['schemas']['Post'];
type CreatePostRequest = components['schemas']['CreatePostRequest'];
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
export async function fetchPosts(limit?: number): Promise {
const url = new URL(`${API_BASE_URL}/posts`);
if (limit !== undefined) {
url.searchParams.set('limit', String(limit));
}
const response = await fetch(url.toString());
if (!response.ok) {
throw new Error(`Failed to fetch posts: ${response.statusText}`);
}
return response.json();
}
export async function createPost(req: CreatePostRequest): Promise {
const response = await fetch(`${API_BASE_URL}/posts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(req),
});
if (!response.ok) {
throw new Error(`Failed to create post: ${response.statusText}`);
}
return response.json();
}
React Serverコンポーネントで使う例:
import { fetchPosts } from '@/lib/api';
export default async function PostsPage() {
const posts = await fetchPosts(20);
return (
{posts.map((post) => (
{post.title}
{post.body}
))}
);
}
post.createdAtがstring型だとOpenAPI仕様から自動判定され、IDEのオートコンプリートも正確に機能します。
ステップ5:Claude Codeを使った生成フロー自動化
OpenAPI仕様を変更するたびに、手動で生成コマンドを実行するのは手間です。Claude Codeを使って、このフロー全体を自動化できます。
package.jsonに以下のスクリプトを追加:
{
"scripts": {
"codegen": "npm run codegen:api && npm run codegen:frontend",
"codegen:api": "oapi-codegen -config .oapi.cfg.yaml openapi.yaml > api/generated.go",
"codegen:frontend": "openapi-typescript openapi.yaml -o ./types/api.ts",
"dev": "npm run codegen && concurrently \"next dev\" \"go run ./cmd/server\""
}
}
Claude Codeのファイルウォッチャー機能を使えば、openapi.yamlが変更されるたびに自動生成を走らせることも可能です。詳細はClaude CodeのHooks機能を活用して開発を自動化しようという記事も参考になります。
よくあるハマりどころと解決策
本番環境でこのアプローチを使う際、いくつかの注意点があります。
日付フォーマットの齟齬
OpenAPI仕様ではformat: date-timeとなっていても、GoとTypeScriptで日付の扱い方が異なります。
- Go:
time.Time(RFC3339でマーシャリング) - TypeScript:
string(ISO 8601として扱う)
フロント側でJavaScriptのnew Date()に渡すときは注意が必要です。タイムゾーン情報が含まれていない場合、ローカルタイムゾーンで解釈されることがあります。
Nullableフィールドの定義
OpenAPI仕様でオプショナルフィールドを定義するときは、required配列を明確に指定します:
Author:
type: object
required:
- id
- name
properties:
id:
type: string
name:
type: string
email:
type: string
nullable: true
こう書くと、Goではemail *string(ポインタ)として生成され、TypeScriptではemail?: string | nullとなります。
環境別のベースURLの切り替え
開発・テスト・本番環境でAPIのベースURLが異なる場合、環境変数で制御します。
フロント側(Next.js):
// lib/api.ts
const getApiBaseUrl = (): string => {
if (typeof window === 'undefined') {
// SSRサーバー側
return process.env.API_URL_INTERNAL || 'http://localhost:8080';
}
// クライアント側
return process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
};
バック側(Go):
package main
import (
"net/http"
"os"
)
func init() {
allowOrigin := os.Getenv("CORS_ORIGIN")
if allowOrigin == "" {
allowOrigin = "http://localhost:3000"
}
// CORS設定に allowOrigin を使用
}
型生成のパフォーマンス
大規模なAPIの場合、生成時間が気になることもあります。100以上のエンドポイントを持つOpenAPI仕様でベンチマークしてみると:
- oapi-codegen(Go側):約200-300ms
- openapi-typescript(フロント側):約100-150ms
- 合計:400-450ms
開発時のホットリロードではそこまで気になりませんが、CI/CDパイプラインで複数回実行する場合は、キャッシュを活用するのが良いでしょう。
実装を進める上でのベストプラクティス
フルスタック開発でこのアプローチを使う場合、以下のルールを決めておくと運用がスムーズです。
- OpenAPI仕様はバージョン管理の中心に:openapi.yamlを「真実の源」として扱い、必ず先に更新する
- 生成コードは手編集しない:api/generated.go や types/api.ts は自動生成なので、手で修正してはいけない
- 定期的に型をチェック:npm run codegen && npm run lint を本番前に必ず実行
- バージョン差異の把握:oapi-codegen や openapi-typescript のバージョンを固定し、チーム全体で同じバージョンを使う
- APIドキュメント生成の自動化:openapi-typescript-fetch や swagger-ui-express を組み合わせれば、OpenAPI仕様から自動でAPIドキュメントも生成できる
次のステップ:テストの自動化
型安全性を確保できたら、次はエンドツーエンドテストの自動化です。Playwright や Cypress を使ってフロント側のテストを書き、Go側は httptest パッケージで単体テストを書くことで、両者の統合テストも実装できます。テスト駆動開発に関しては、Claude Codeでテスト駆動開発を実践するという記事も参考になるでしょう。
まとめ
- OpenAPI仕様を「真実の源」として、フロント・バックエンドの型定義を一元管理する
- oapi-codegen と openapi-typescript で自動生成することで、型の齟齬を防ぎ、開発スピードを上げられる
- Claude Codeでコード生成フローを自動化すれば、OpenAPI仕様の変更が即座に両側に反映される
- 日付フォーマット、Nullable の定義、環境別の設定など、細かい部分の統一が本番運用では重要
- 型安全性が確保できたら、E2Eテストの自動化へ進むのが次のステップ
よくある質問(FAQ)
既存のプロジェクトでOpenAPI仕様を後付けできる?
可能です。既存のGoのハンドラーやNext.jsのAPIルートから、openapi-generator や手書きでOpenAPI仕様を作成できます。ただし、型定義が複雑な場合は手作業が増えるので、新規プロジェクトからこのアプローチを始めるのが理想的です。
GraphQLを使っている場合はどうするべき?
GraphQLの場合は、Apollo Client のコード生成機能や graphql-codegen を使って、スキーマから型を自動生成できます。RESTful APIよりもスキーマと実装の結合度が強いため、型の同期がさらに容易になります。
マイクロサービスが複数ある場合、各サービスで別々のOpenAPI仕様を持つべき?
はい、各マイクロサービスが独立した OpenAPI仕様を持つべきです。その際、共通のスキーマ定義(例:エラーレスポンス、ユーザー型)は $ref で参照し、DRY原則を保つとよいでしょう。Claude Codeでモノレポのマルチプロジェクト管理を行う実践ガイドも参考になります。

