APIの総合テックメディア

運営会社

2026-01-20

Hono + Orval + TypeSpecでAPIファースト開発

minomo

Tech Lead

はじめに

現在、Chat用BaaS Rheel Chat API の開発を進めているテックリードのminomoです。

このサービスはAPI自体が商品となるため、自然とAPIファーストで開発を進めることになり、技術選定の過程でHono + Orval + TypeSpecという組み合わせにたどり着きました。

この記事では、その技術選定の経緯と、実際のプロダクト(Rheel Chat API)での採用事例を紹介します。

この記事で分かること:

  • TypeSpec + Orval + Honoの開発フロー

  • Orvalの設定と生成されるコードの構成

  • 実際に使ってみて感じたメリット・課題


技術選定の経緯

下記については事前に決めていました。

  • API定義形式:OpenAPI

  • 実行環境:Node.js

APIを提供サービスとしてするため、そのAPI仕様についてOpenAPIを使って共有することができればユーザーにメリットがあると考えたためです。

また、Node.jsについては社内でTypeScriptの知見をもつエンジニアが多かったため、採用しました。

TypeSpecの採用経緯

OpenAPIのYAMLは規模が大きくなると膨大になり、保守が困難になる懸念がありました。TypeSpecであればTypeScriptに似た構文で同等の定義を簡潔に記述でき、ファイル分割で構造的に管理できるため採用しました。

Orval × Honoの採用経緯

OpenAPIからコード生成するツールを探したときに、一番開発が楽そうなのが、Orval × Honoの組み合わせでした。

別のHTTPフレームワーク(Express, Nest.js, Fastify)でもOpenAPIからコード生成できるツールを調査しましたが、Orval × Honoのように型生成、バリデーション、ルーター生成、Contextの型付けまで一貫して行えるようなツール見つけられませんでした。


開発フローの全体像

開発の流れとしては、TypeSpecでAPIを定義し、OpenAPIにコンパイル、Orvalでコードを生成、Handlerにビジネスロジックを実装という流れになります。

TypeSpec(API定義)
    ↓ tsp compile
OpenAPI(中間ファイル)
    ↓ orval
生成コード
├── Router(ルーティング定義)
├── Zod(バリデーション)
├── Schemas(TypeScript型)
├── Context(型付きContext)
└── Handler(handlerのスタブ)
    ↓
Handlerにビジネスロジックを実装

TypeSpecを書くだけでRouter、バリデーション、型が全て揃い、Handler以降の実装に集中できます。


TypeSpecでのAPI定義

TypeSpecでモデルとAPIオペレーションを定義します。

共通の型定義(common.tsp)

namespace SampleAPI;

@pattern("^[a-zA-Z0-9_-]+$")
@minLength(1)
@maxLength(100)
scalar UserId extends string;

モデル定義(users.tsp)

import "@typespec/http";
import "./common.tsp";

using TypeSpec.Http;

namespace SampleAPI.Users;
using SampleAPI;

/** ユーザー情報 */
model User {
  /** ユーザーID(レスポンス時のみ) */
  @visibility([Lifecycle.Read](<http://Lifecycle.Read>))
  id: UserId;

  /** 表示名 */
  display_name: string;
}

オペレーション定義(operations/users.tsp)

import "@typespec/http";
import "../models/users.tsp";

using Http;
using SampleAPI.Users;

@route("/users")
@tag("users")
namespace SampleAPI.Users;

@get
@summary("ユーザー一覧の取得")
op getUsers(
  @query @minValue(1) @maxValue(100) limit?: int32 = 20,
  @query include_deleted?: boolean = false,
): User[];

@post
@summary("ユーザーの作成")
op createUser(@body body: User): User;

@visibility([Lifecycle.Read](<http://Lifecycle.Read>)) を使うことで、idフィールドがレスポンスでのみ返され、リクエストボディには含まれなくなります。これはOpenAPIのreadOnly: trueに変換されます。


生成されるOpenAPI

TypeSpecから生成されるOpenAPI仕様です。

openapi: 3.0.0
info:
  title: Sample API
  version: 0.0.0
tags:
  - name: users
paths:
  /users:
    get:
      operationId: Users_getUsers
      summary: ユーザー一覧の取得
      tags:
        - users
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: include_deleted
          in: query
          schema:
            type: boolean
            default: false
      responses:
        '200':
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Users.User'
    post:
      operationId: Users_createUser
      summary: ユーザーの作成
      tags:
        - users
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Users.User'
      responses:
        '200':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Users.User'
components:
  schemas:
    UserId:
      type: string
      minLength: 1
      maxLength: 100
      pattern: ^[a-zA-Z0-9_-]+$
    Users.User:
      type: object
      required:
        - id
        - display_name
      properties:
        id:
          allOf:
            - $ref: '#/components/schemas/UserId'
          description: ユーザーID(レスポンス時のみ)
          readOnly: true  # ← @visibilityの効果
        display_name:
          type: string
          description: 表示名

Orvalの設定

// orval.config.ts
import { defineConfig } from 'orval'

export default defineConfig({
  sampleApi: {
    input: {
      target: './openapi/openapi.yaml',
    },
    output: {
      mode: 'tags-split',
      client: 'hono',
      target: 'src/generated',
      schemas: 'src/generated/schemas',
      override: {
        hono: {
          handlers: 'src/handlers',
          validatorOutputPath: 'src/generated/validator.ts',
          compositeRoute: 'src/generated/routes.ts',
        },
        zod: {
          coerce: {
            query: ['number'],
          },
        },
      },
      biome: true,
    },
  },
})

こちらは実際プロジェクトで利用している設定です。

  • mode: tags-split

    タグごとにサブディレクトリを作成し、その中でさらにファイルを分割

  • schemas:スキーマの出力先

    tags-splitの場合指定しないとスキーマが出力されない

  • compositeRoute:全タグのルートをまとめるファイル

    tags-splitの場合指定しないとrouteファイルがタグごとに分かれます

  • validatorOutputPath:バリデーターの出力先

    tags-splitの場合指定しないとファイル名が**filename.validator.ts** となり違和感があるので指定推奨

  • handler

    handlerの出力先を指定できる(指定がない場合はtargetの中でタグごとに作成される)

  • biome: true

    ファイル生成後にbiomeでフォーマットを行う(prettierも指定可能)


コード生成物の構成

実際に生成されるファイルは下記のような形になります。

src/
├── generated/                
│   ├── routes.ts             # compositeRoute:全ルートをまとめる
│   ├── validator.ts          # 共通zValidator
│   ├── schemas/              # スキーマ(モデルごとにファイル分割)
│   │   ├── index.ts
│   │   ├── userId.ts
│   │   ├── usersGetUsersParams.ts
│   │   └── usersUser.ts
│   └── users/                # タグごとのディレクトリ
│       ├── users.context.ts  # 型付きContext
│       └── users.zod.ts      # Zodスキーマ
└── handlers/                 # ハンドラー(初回のみ生成、以降は編集可)
    ├── usersGetUsers.ts
    └── usersCreateUser.ts

Router

Orvalが生成するHonoルーター(src/generated/routes.ts

import { Hono } from 'hono';
import { usersGetUsersHandlers } from '../handlers/usersGetUsers';
import { usersCreateUserHandlers } from '../handlers/usersCreateUser';

const app = new Hono()

app.get('/users',...usersGetUsersHandlers);
[app.post](<http://app.post>)('/users',...usersCreateUserHandlers)

export default app

compositeRouteオプションを指定すると、全ルートが1ファイルにまとめられます。指定しない場合はタグごとにusers/users.tsのようなルーターが生成されます。

Zod

Orvalが生成するZodスキーマ(src/generated/users/users.zod.ts

import { z as zod } from 'zod';

// クエリパラメータ
export const usersGetUsersQueryLimitDefault = 20;
export const usersGetUsersQueryLimitMax = 100;
export const usersGetUsersQueryIncludeDeletedDefault = false;

export const usersGetUsersQueryParams = zod.object({
  "limit": zod.coerce.number().min(1).max(usersGetUsersQueryLimitMax).default(usersGetUsersQueryLimitDefault),
  "include_deleted": zod.boolean().optional()
})

// リクエストボディ - idフィールドが含まれない!(readOnlyの効果)
export const usersCreateUserBody = zod.object({
  "display_name": zod.string().describe('表示名')
}).describe('ユーザー情報')

// レスポンス - idフィールドが含まれる
export const usersCreateUserResponse = zod.object({
  "id": zod.string().min(1).max(100).regex(/^[a-zA-Z0-9_-]+$/).describe('ユーザーID'),
  "display_name": zod.string().describe('表示名')
}).describe('ユーザー情報')

@visibility([Lifecycle.Read](<http://Lifecycle.Read>)) の効果により、usersCreateUserBodyにはidが含まれず、usersCreateUserResponseにはidが含まれています。

@visibility については、TypeSpecでも目玉機能の一つだと思っているので、TypeScriptの生成コードでも問題なく利用できそうで安心しました。

Schemas(TypeScript型定義)

schemas/ディレクトリにはTypeScript interfaceが生成されます。Zodスキーマとは別に、純粋な型定義として利用できます。

// src/generated/schemas/userId.ts
/**
 * @minLength 1
 * @maxLength 100
 * @pattern ^[a-zA-Z0-9_-]+$
 */
export type UserId = string;
// src/generated/schemas/usersUser.ts
import type { UserId } from './userId';

/**
 * ユーザー情報
 */
export interface UsersUser {
  /** ユーザーID(レスポンス時のみ) */
  readonly id: UserId;
  /** 表示名 */
  display_name: string;
}

TypeSpecで定義した@visibility([Lifecycle.Read](<http://Lifecycle.Read>))は、readonly修飾子として反映されています。

Context

Orvalは各エンドポイントに対応した型付きContextを生成します(src/generated/users/users.context.ts)。

export type UsersGetUsersContext<E extends Env = any> = Context<
  E, 
  '/users', 
  { in: { query: UsersGetUsersParams }, out: { query: UsersGetUsersParams } }
>

export type UsersCreateUserContext<E extends Env = any> = Context<
  E, 
  '/users', 
  { in: { json: NonReadonly<UsersUser> }, out: { json: NonReadonly<UsersUser> } }
>

この型付きContextをハンドラーで使用することで、c.req.valid('query')c.req.valid('json')の戻り値に正しい型が推論されます。

Handler

Orvalが生成するハンドラーのスタブ(src/handlers/usersCreateUser.ts

import { createFactory } from 'hono/factory';
import { zValidator } from '../generated/validator';
import { UsersCreateUserContext } from '../generated/users/users.context';
import {
  usersCreateUserBody,
  usersCreateUserResponse
} from '../generated/users/users.zod'

const factory = createFactory();

export const usersCreateUserHandlers = factory.createHandlers(
  zValidator('json', usersCreateUserBody),      // リクエストバリデーション
  zValidator('response', usersCreateUserResponse), // レスポンスバリデーション
  async (c: UsersCreateUserContext) => {
    // ここにビジネスロジックを実装
  },
);

HonoのFactoryヘルパーを利用してHandlerのスタブを作成し、下記が提供されます。

  • Contextにリクエストの型がつく

  • MiddlewareでrequestとresponseをZodでバリデーション

ここにビジネスロジックを追加していく形になります。


利用方法

import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import routes from './generated/routes'

const app = new Hono()
app.route('/', routes)

serve({
  fetch: app.fetch,
  port: 3000,
})

Honoインスタンスを作成し、生成されたroutesを組み込むだけです。

新たにエンドポイントを追加した場合、そのエンドポイントはroutesの内部に追加されるため特にコードを変更する必要がありません。

また、Middlewareを組み込みたい場合は、app.route('/', routes) の手前に記述することで組み込みが可能です。


良かった点

TypeSpecとOrvalの相性が良い

TypeSpecの機能(@visibility、@pattern、@minLength等)は全てOpenAPIに正しく変換され、Orvalでも問題なくコード生成されました。OpenAPIを介しているので当然といえば当然ですが、事前の懸念が払拭されて安心しました。

以前の記事(TypeSpecでスキーマ駆動開発)で紹介したTypeSpecの機能も問題なく利用できています。

型付きContextでリクエストの型補完が効く

Orvalは各エンドポイントに対応した型付きContextを生成します。

この型付きContextを使うことで、ハンドラー内でc.req.valid('query')c.req.valid('json')を呼び出したときに、正しい型が推論されます。

async (c: UsersGetUsersContext) => {
  const query = c.req.valid('query')
  
  // 型が推論されるのでIDEの補完が効く
  const limit = query.limit              // number
  const includeDeleted = query.include_deleted  // boolean | undefined
  
  // ...
}

レスポンスの型が提供される

レスポンスの型を指定する方法は2つあります:

  • 生成されたTypeScript interface(UsersUser)を使う

  • Zodスキーマから z.infer で型を導出する(推奨)

なぜz.inferが推奨か

zValidator('response', schema)でバリデーションに使うスキーマと同じものから型を取ることで、型とバリデーションの一致が保証されます。バリデーションのコードは自動生成されるため、レスポンスにも同じスキーマを使うということをルール化できると、どの型を使えば良いか迷うこともなかったです。

import type { z } from 'zod'
import { usersCreateUserBody, usersCreateUserResponse } from '../generated/users/users.zod'

export const usersCreateUserHandlers = factory.createHandlers(
  zValidator('json', usersCreateUserBody),
  zValidator('response', usersCreateUserResponse),  // バリデーション
  async (c: UsersCreateUserContext) => {
    const body = c.req.valid('json')
    // バリデーションと同じZodスキーマから型を導出 → バリデーション内容と型が必ず一致
    const response: z.infer<typeof usersCreateUserResponse> = {
      id: crypto.randomUUID(),
      display_name: body.display_name,
    };
    return c.json(response, 201);
  },
);

柔軟にMiddlewareの追加が可能

Orvalが生成するハンドラーはfactory.createHandlers()の引数としてミドルウェアとハンドラーを受け取る形式なので、生成されたバリデーションの間に独自のミドルウェアを差し込むことができます。

export const usersCreateUserHandlers = factory.createHandlers(
  zRequestValidatorWrapper('json', usersCreateUserBody),
  apiAccessMiddleware, // -> 自作middlewareを追加
  zValidator('response', usersCreateUserResponse),
  async (c) => {},
)

上記の例では、Orvalが生成したバリデーションの間に、独自のアクセス制御ミドルウェアを追加しています。このように、生成されたコードをベースにしつつ、プロジェクト固有の要件に合わせて柔軟に拡張できます。

型による変更の安全性

TypeSpecでAPI定義を変更してコードを再生成すると、影響範囲が型エラーとして検出されます。

例えば、Userモデルにemailフィールドを追加した場合:

// TypeSpecでフィールドを追加
model User {
  id: UserId;
  display_name: string;
  email: string;  // 追加
}

コードを再生成すると、usersCreateUserResponseのZodスキーマにemailが追加され、ハンドラー内でemailを返していない箇所が型エラーになります。

これにより、修正すべき箇所がコンパイル時に明確になるため、変更漏れを防ぐことができます。

AIコーディングとの親和性

TypeSpec + Orval + Honoの構成は、生成AIを使った開発と相性が良かったです。

生成AIは事前に埋まっている情報が多いほど、出力精度が向上します。TypeSpec + Orvalは、まさにこの「事前情報」を最大化するアプローチです。

さらにPrismaを使ってDBのスキーマを定義している場合、AIがTypeSpecの定義とPrismaの定義を参照することで穴埋め式にコードを書いてくれます。

ゼロからAIに任せてコーディングする場合にも、AIが作成するTypeSpecさえ確認しておけば、OASや型、バリデーション、routerの確認は最低限で良いためレビューも楽になります。

使ってみて微妙に感じた点

コード生成時の出力先の指定がもう少し柔軟だと嬉しい

tags-splitモードを指定すると、タグごとにZodスキーマやContextが分割されますが、いくつかの制約があります。

Schemas

タグに関係なくschemas/にまとめて出力されます。タグはオペレーションにしか付けられないため仕方ないですが、namespaceごとに分割できると嬉しいところです。

Handler

Handlerについては生成コードの中で唯一編集するファイルになるので、プロジェクトで利用しているorval.config.tsでは出力先をhandlers/と指定して下記のような構成にしています。

  • generated/配下のコード = 編集しない

  • handlers/ 配下のコード = 編集対象

ただし、出力先ディレクトリを指定すると、tags-splitモードでもタグ単位での ディレクトリ分割ができなくなり、すべてのHandlerがフラットに配置されてしまうのが難点です。

今の所、IDEの検索機能もあるのでそこまで困っていませんが、プロジェクトが大規模になると気になってきそうです。

クエリパラメータのbooleanバリデーションが課題

クエリパラメータはHTTPの仕様上、常に文字列として送られてきます。しかし、OpenAPIでintegerbooleanと定義すると、Orvalはその型でZodスキーマを生成するため、バリデーションエラーになります。

Orvalのcoerceオプションを使うと、生成されるZodスキーマがzod.coerce.number()のように文字列から数値への変換を行ってからバリデーション行うようになり解決可能です。

// orval.config.ts
export default defineConfig({
  sampleApi: {
    output: {
      override: {
        zod: {
          coerce: {
            query: ['number'],
          },
        },
      }
    },
  },
})

ただし、booleanには問題があります。zod.coerce.boolean()はJavaScriptのBoolean()を使うため、"false"という文字列がtrueに変換されてしまいます(空でない文字列はすべてtrue)。そのため、coerceの対象からbooleanを除外しています。

クエリパラメータのbooleanフィールドについては今の所、カスタムバリデーターで対処しています

import { z } from 'zod';
import { usersGetUsersQueryParams } from '../generated/users/users.zod'

// 生成されたスキーマを拡張し、booleanフィールドのみ上書き
const customQueryParams = usersGetUsersQueryParams.extend({
  include_deleted: z
    .string()
    .optional()
    .transform((val) => val?.toLowerCase())
    .refine((val) => !val || val === 'true' || val === 'false')
    .transform((val) => val === 'true'),
})

export const usersGetUsersHandlers = factory.createHandlers(
  // zValidator('query', usersGetUsersQueryParams),  // 削除(自動生成のバリデーション)
  zValidator('query', customQueryParams),  // カスタムスキーマを使用
  // ...
)

型名にNamespaceが含まれてしまう

TypeSpecでNamespaceを分けてモデルを定義すると、生成されるOpenAPIのスキーマ名は{Namespace}.{ModelName}となり、TypeScriptのInterfaceも{Namespace}{ModelName}のように定義されます。

// TypeSpec定義
namespace SampleAPI.Models.Users {
  model User { ... }
}
# 生成されるOpenAPI
components:
  schemas:
    Models.Users.User: # ← ルート(SampleAPI)以降のNamespaceがプレフィックスに
      type: object
// Orvalが生成するTypeScript型
export interface ModelsUsersUser { ... }

名前の衝突を避けるため、仕方のない仕様だとは思いますが、namespaceの階層を深くしてしまうとModel名が長くなり扱いにくくなりそうです。

回避策として、@friendlyNameを利用することでModel名は指定することは可能です。

ただ、各Modelに指定が必要なため小規模なプロジェクトであればnamespaceを利用せずにトップレベルに全モデルを配置するのが良さそうに思いました。


まとめ

Hono + Orval + TypeSpecの組み合わせを1年ほど運用してみて、導入時はTypeSpecにより生成されたOASがOrvalと相性悪かったりしないかなどの懸念がありましたが、メリットの方が大きいと感じています。

この構成で得られたこと:

  • TypeSpecで簡潔にAPI定義を管理できる

  • Orvalで型・バリデーション・ルーターが自動生成される

  • Honoの柔軟なミドルウェア構成で拡張しやすい

  • 生成AIとの相性も良く、開発効率が上がる

今回はAPIサーバー側のみの紹介でしたが、OrvalはNext.js等でのAPIクライアントの生成にも対応しています。こちらも使い勝手が良かったのでAPIを使った開発を行う場合は是非試してみて下さい。


本メディアはAPI特化の開発会社であるECU株式会社が運営しています。記事についてのご質問やAPIに関するご相談・お問い合わせはお気軽にお問い合わせフォームまで。

SNSでシェア

© ECU, Inc.