OpenAPI TypeScriptで実現する型安全な開発

はじめに
フロントエンドとバックエンドがAPIを通じて連携する構成は、多くのWebアプリケーションにおいて一般的です。
そのような環境において、双方のやり取りに用いられるデータの型にズレが生じると、思わぬ不具合や保守性の低下を招くことがあります。
TypeScriptのような静的型付け言語は、コードの安全性や可読性を高める手段として広く活用されていますが、API通信の部分になると、手動での型定義やany
の多用により、その恩恵を活かしづらい場面も少なくありません。
たとえば、以下のような課題に直面したことはないでしょうか。
- バックエンドのレスポンス定義を変更したことにフロントエンドが気づかずバグが発生する
- フロントエンド側で
any
を多用し、型の恩恵が受けられない - OpenAPIの仕様書は存在するが、実装と乖離しており信頼できない
この記事では、これらの課題を解決する手段としてOpenAPI TypeScript(公式サイト ⧉)を活用し、OpenAPI定義を中心に据えた、型安全なAPI連携の開発フローを構築する方法をご紹介します。
具体的にはNestJS(バックエンド)とTypeScriptベースのフロントエンドを例に解説しますが、構成そのものはフレームワーク非依存で利用可能です。
Webアプリケーション開発において型の整合性に課題を感じている方の一助となれば幸いです。
システムの全体構成
OpenAPI TypeScriptを活用した開発フローでは、OpenAPI仕様を軸に、バックエンド・フロントエンド双方で共通の型情報を共有することが重要なポイントとなります。
ここでは、その全体的な構成の流れを簡単に整理してみます。
- NestJSでOpenAPI仕様を生成 → openapi.yml
- openapi-typescriptで型定義を生成 → api-types.ts
- OpenAPI Fetchで型安全なクライアント生成
- フロントエンドで型を活用して開発
このようにOpenAPI仕様書を中心に据えることで、API設計と実装のズレを最小限に抑え、型の乖離による不具合を防ぐことができます。
ステップごとの実装
1. NestJSでOpenAPI仕様(openapi.yml)を生成する
まず、@nestjs/swagger
を使用して、OpenAPI形式の仕様書を自動生成します。
NestJSでOpenAPI形式のドキュメントを作成する手順については、過去の記事でも紹介しておりますのでこちらも合わせてご参照ください。
NestJSを用いたWebAPIサーバーの開発と、仕様書作成/APIテストの効率化
以下はエンティティクラスの定義例です。
@ApiProperty
デコレータを使用して、プロパティの情報を記述します。
import { ApiProperty } from "@nestjs/swagger";
export class UserEntity { @ApiProperty({ description: "ユーザID", example: 1, }) id: number;
@ApiProperty({ description: "ユーザの表示名", example: "アルファ太郎", }) name: string;
@ApiProperty({ description: "ユーザアカウント(ログインID)", example: "alphataro", }) account: string;}
Controllerでも、@ApiResponse
などのデコレータを使用して、レスポンス情報を記述します。
import { Controller, Get } from "@nestjs/common";import { ApiOperation, ApiResponse } from "@nestjs/swagger";import { UserEntity } from "./user.entity";
@Controller('users')export class UserController {
constructor(private readonly userService: UserService) {}
@Get() @ApiOperation({ summary: 'ユーザ一覧取得' }) @ApiResponse({ status: 200, description: 'ユーザ一覧返却', type: [UserEntity], }) getAll() { return this.userService.findAll(); }
@Get('/:id') @ApiOperation({ summary: '特定ユーザ取得' }) @ApiResponse({ status: 200, description: '指定したユーザを取得する', type: UserEntity, }) @ApiResponse({ status: 404, description: 'ユーザが存在しない', type: NotFoundError, }) get(@Req() req: Request, @Param('id', ParseIntPipe) id: number) { return this.userService.get(id); }}
main.ts
にはドキュメント生成のコードを記載します。
バックエンドを起動すれば、OpenAPI形式のファイルが生成されます。
import { NestFactory } from '@nestjs/core';import { AppModule } from './app.module';import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';import { writeFileSync } from 'fs';import * as path from 'path';import { dump } from 'js-yaml';
async function bootstrap() { const app = await NestFactory.create(AppModule);
const config = new DocumentBuilder() .setTitle('API docs') .setDescription('API仕様書です。') .setVersion('1.0') .build(); const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup('api', app, document);
// ファイルに出力 const outputPath = path.resolve(process.cwd(), 'openapi.yml'); writeFileSync(outputPath, dump(document, {}));
await app.listen(process.env.PORT ?? 3000);}bootstrap();
2. OpenAPI TypeScriptで型定義を生成する
生成されたopenapi.yml
からTypeScriptの型を自動生成します。
npm install -D openapi-typescriptnpx openapi-typescript ./openapi.yml -o ./src/api-types.ts
このapi-types.ts
には、エンドポイントごとのパラメータやレスポンス型などが型定義として展開されます。
補足:型定義ファイルのカスタマイズ
出力される型定義ファイルに特定の変換や加工を加えたい場合は、専用のスクリプトファイルを用意することで柔軟に対応できます。
私が関わっているプロジェクトでは、以下を実現するためにスクリプトファイルを作成しました。
- バイナリ形式のレスポンスをBlob型として扱う
- 具体的な方法は公式ドキュメント ⧉をご参照ください
- 生成する型定義ファイルの先頭に、手動で変更してはいけない旨の警告と、型の再生成方法を追記する
3. フロントエンドで型安全なAPIクライアントを利用する
OpenAPI TypeScriptで生成した型を利用して、型安全なAPIクライアントを作成します。
OpenAPI Fetchを使用すれば、エンドポイントのURIを元に自動的に適切な型が適用されます。
import createClient, { Client } from "openapi-fetch";import type { paths } from "../../api-types"; // ここで作成した型定義ファイルを指定します
export class WebAPI { private static _instance: WebAPI | undefined; private apiClient: Client<paths, `${string}/${string}`>;
private constructor() { this.apiClient = createClient<paths>({ baseUrl: "http://localhost:3000", }); }
public static get instance(){ if (!this._instance){ this._instance = new WebAPI(); } return this._instance; }
public async getUsers() { const { data, error } = await this.apiClient.GET("/users"); if (!data) throw new Error(error); return data; // ★バックエンドで定義した通りの型情報になっています }
public async getUser(userId: number) { const { data, error } = await this.apiClient.GET("/users/{id}", { params: { path: { id: userId } }, }); if (!data) throw new Error(error); return data; // ★バックエンドで定義した通りの型情報になっています }}
手動で型を定義することなく、簡単に適切な型が適用できました。
もちろんエディタ上でも型情報が表示されますので、開発体験も向上します。
補足:URIの指定について
前述のコードでも示した通り、クライアントにはGET、POST、PUT、DELETEといったネイティブFetch APIをラップしたメソッドが用意されています。
これらのメソッドの第一引数にはURIを渡しますが、引数の型は文字列型ではなく、文字列リテラル型として定義されています。
そのため、タイプミスなどにより誤って存在しないAPIを指定してしまう可能性を排除できます。
■URIが補完されている様子
■存在しないAPIを指定したときの様子
型情報を直接参照したい場合
openapi-fetch
を使用することで、APIクライアントの戻り値には自動で適切な型が適用されます。
ただ、場合によってはレスポンス型を手動で明示的に利用したい場面もあるかもしれません。
そのような場合は、OpenAPI TypeScriptで生成されたcomponents.schemas
やoperations
を直接参照することで、個別の型として利用できます。
import type { components } from '../../api-types';
// componentsから型を得るexport type UserDto = components['schemas']['UserEntity'];
// operationsから型を得るexport type UserDto2 = operations['UserController_get']['responses'][200]['content']['application/json; charset=utf-8'];
型の整合性チェックの実例
適切な型の管理によって得られる恩恵の一例をご紹介します。
たとえば、UserEntity
のname
プロパティをnickname
に変更してみます。
@ApiProperty({ description: 'ニックネーム', example: 'ニックネーム太郎'})nickname: string; // name → nickname に変更
この状態でopenapi.json
を再生成し、さらにopenapi-typescript
で型を更新すると、フロントエンドの以下のようなコードにエラーが出るようになります。
このように、バックエンドでの仕様変更が即座にエラーとして現れることで不整合を検出できます。
実際に導入してみて感じたメリット
私が関わっているプロジェクトでは、型安全を目的にこの仕組みを導入しましたが、それ以外の利点も感じられました。
バックエンドで記述した情報をJSDocとして活用できる
NestJSで@ApiProperty
や@ApiResponse
といったデコレータに記述した情報は、OpenAPI TypeScriptを通じてJSDocコメントとして型定義に反映されます。
export class UserEntity { ︙ @ApiProperty({ description: 'ユーザアカウント(ログインID)', example: 'alphataro' }) account: string; ︙
自動生成された型定義ファイル
export interface components { schemas: { UserEntity: { ︙ /** * @description ユーザアカウント(ログインID) * @example alphataro */ account: string; ︙
これらの情報はエディタでも確認できるため、チームメンバーの理解の助けになりました。
OpenAPI FetchのMiddlewareで通信処理を一元管理できる
openapi-fetch
は、リクエストやレスポンスに対して共通処理を挿入できるMiddleware機能を備えています。
// Middlewareのインポートが必要// import createClient, { Middleware, Client } from "openapi-fetch";
// Middlewareの定義(例として簡易的な処理を書いています)const authMiddleware: Middleware = { onRequest: async ({ request }) => { const token = localStorage.getItem("access_token"); if (token) { // 認証Tokenをセット request.headers.set( 'Authorization', `Bearer ${token}`, ); } return request; }, onResponse: async ({ response }) => { if (!response.ok) { console.error("API Error:", response.status, response.statusText); } },};
this.apiClient = createClient<paths>({ baseUrl: "http://localhost:3000",});
// Middlewareの使用this.apiClient.use(authMiddleware);
Middlewareの活用により、フロント側のAPIロジックがシンプルになり、テストしやすくなりました。
他にも以下のようなケースでの活用が考えられます。
- 共通のリクエストログ出力
- エラーレスポンスなどの共通のトースト表示
- Content-TypeやAccept-Languageヘッダーの付与
まとめ
項目 | 内容 |
---|---|
OpenAPI TypeScript | OpenAPI仕様から自動でTypeScript型を生成 |
OpenAPI Fetch | 型定義と連携したクライアント生成が可能。Middlewareで共通処理も挿入できる |
JSDoc連携 | @ApiProperty や@ApiResponse などの情報が型補完に活用され、開発体験向上に寄与 |
Middleware活用 | 通信処理(認証・ロギングなど)の共通化と保守性の向上 |
今回の記事ではopenapi-fetch
を中心に紹介しましたが、Reactを使っている場合は openapi-react-query
⧉ のようなツールもあります。
おわりに
OpenAPI TypeScriptを活用することで、サーバーとクライアントのAPI定義を統一し、開発効率と保守性の高い、型安全なAPI連携を実現することができます。
この記事では、NestJSを用いたOpenAPI仕様の生成と、TypeScriptフロントエンドでの活用例をご紹介しました。
この構成は他のフレームワークやバックエンド技術にも十分適用可能です。
また、記事内では詳しく触れませんでしたが、実運用においてはopenapi.yml
の変更をトリガーとしてCI/CD上で型定義を自動生成する仕組みを導入することで、より確実に型の乖離を防ぐことができます。
紹介したアプローチは、API設計と実装の信頼性を高めたい現場において、有力な選択肢となり得ます。
スムーズに導入できる仕組みなので、興味のある方はぜひ試してみてください。