freeeの開発情報ポータルサイト

OpenAPI ではなく TypeSpec を読み書きするスキーマ駆動開発

支出管理開発本部で事業部横断テックリードをしている @ogugu です。
広く複雑で大規模になりつつある支出管理のアーキテクチャについて、以下の連載形式でご紹介していきます。

今回は、TypeSpec を中心にしたスキーマ駆動開発をご紹介します。

結論からいうと、筆者は TypeSpec について「OpenAPI からの移行コストや技術的ロックインリスクを伴わず、開発体験を向上する最高のツール」と評価しています。その理由を順にご紹介します。

TypeSpec とは

まず、TypeSpec の特徴を簡潔にまとめておきます。

  • Microsoft が開発した IDL (インターフェース定義言語)
  • TypeScript にインスパイアされた簡潔かつ馴染みやすい構文
  • OpenAPI では表現が難しい Generics & Intersections & Unions 的表現が可能
  • Emitter を実装して任意の形式に出力でき、OpenAPI と Protobuf をビルトインサポート
  • VSCode Extensions が提供されていて、シンタックスハイライトやコード補完をサポート
  • GitHub 上でのシンタックスハイライトも最近サポートされた
  • カスタム Linter / Emitter を実装でき、Formatter は組み込みのものがある
  • OpenAPI から TypeSpec への移行ツールが用意されている

OpenAPI とのスキーマの比較

実際に OpenAPI とのスキーマ例を比較してみます。

OpenAPI の場合

openapi: 3.0.0
info:
  title: (title)
  version: 0.0.0
tags: []
paths:
  /stores:
    get:
      operationId: Stores_list
      parameters:
        - name: filter
          in: query
          required: true
          schema:
            type: string
      responses:
        '200':
          description: The request has succeeded.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Store'
  /stores/{id}:
    get:
      operationId: Stores_read
      parameters:
        - name: id
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/Store'
      responses:
        '200':
          description: The request has succeeded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Store'
components:
  schemas:
    Address:
      type: object
      required:
        - street
        - city
      properties:
        street:
          type: string
        city:
          type: string
    Store:
      type: object
      required:
        - name
        - address
      properties:
        name:
          type: string
        address:
          $ref: '#/components/schemas/Address'

TypeSpec の場合

import "@typespec/http";

using TypeSpec.Http;

model Store {
  name: string;
  address: Address;
}

model Address {
  street: string;
  city: string;
}

@route("/stores")
interface Stores {
  list(@query filter: string): Store[];
  read(@path id: Store): Store;
}

導入の意思決定

以下は支出管理のアーキテクチャ概要です。

支出管理のアーキテクチャの図解。ドメインによって分離されたマイクロサービスが同期・非同期で連携。サービス間の依存関係は DAG (有向非巡回グラフ) になるように定義。ドメインによって分離されたマイクロサービスが同期・非同期で連携。サービス間の依存関係は DAG (有向非巡回グラフ) になるように定義。Go の API サーバーでは ogen を利用して TypeSpec → OpenAPI から実装を生成。フロントエンドでは orval を利用して TypeSpec → OpenAPI からクライアントコード実装を生成している。
支出管理の全体的なアーキテクチャ

マイクロサービス間の通信は、主に gRPC と PubSub による連携を使い分けています。 一方、Webやモバイルなどのクライアントからは、OpenAPI をベースにした BFF へ問い合わせる形式です。

当初、直接 OpenAPI を読み書きしていましたが、メンバーからは「読み書きが辛い」という不満がありました。 yaml という標準的な表現手段は手堅い反面、冗長になるのがトレードオフです。 Coding Agent が台頭する今、書き辛さはなんとかなっても、一見の読み辛さはどうにもなりません。

また、BFF であれば GraphQL によって API 集約を表現する手段もあります。 freee で導入するにあたり、既存構成からの飛躍や社内標準との乖離が課題でした。 そこで、当時は登場して間もなかった TypeSpec に目をつけました。

この際、特に気になるのが、「成熟したエコシステムがあるか」「技術的なロックインリスクがないか」という点です。
そこは、コード生成の際、必ず OpenAPI を中間成果物として介するという導入方法 でリスクを払拭しています。

これによって、成熟した OpenAPI のエコシステムに乗れる上、TypeSpec を捨てたくなったとしても、生成結果の OpenAPI をマスターにすればよいです。 こうした理由から、明確なデメリットがない一方でメリットが大きいと判断し、導入に踏み切りました。

TypeSpec を取り入れた開発フロー

TypeSpecを中心にしたツールチェーンの図解。図中の内容はその下の文章で解説しています。
TypeSpec を中心にしたツールチェーン

以下の流れでスキーマ定義→コード生成をしています。

  1. TypeSpec によってスキーマを定義
  2. TypeSpec に対する linter formatter の実行
  3. TypeSpec から OpenAPI として出力
  4. バックエンド (Go) は ogen でサーバー実装を生成
  5. フロントエンド (React) は orval で tanstack-query のカスタムフックと msw のモックサーバーを生成

先述の通り、実装生成は OpenAPI を起点にして、TypeSpec へのロックインを防いでいます。

以下のように、Generics を活用している事例もあります。 支出管理では、複数種類のマスタデータのインポート機能があり、その結果の IF を Generics を使ってまとめています。

@doc("エラー解析結果。1000件を超える場合があるので署名urlを返す")
model ValidateResult<T> {
  @doc("エラー行数")
  error_rows_count: int64;
  @doc("エラー行情報")
  error_rows: PreviewRow<T>[];
  @doc("エラー情報のCSV署名URL")
  error_csv_url: string;
}

カスタム Linter / Decorator

また、TypeSpec ではカスタム Linter や Decorator を容易に実装することができます。

例えば、弊チームの @masciimascii/typespec-decorator-int64-as-string という Decorator を実装しています。
int64 のパラメーターに @int64AsString というデコレーターを付与すると OpenAPI 側で type: string format: int64 と出力されるものです。
JavaScript の Number.MAX_SAFE_INTEGER を超える値が入りうる場合にこのような設定にすることで、フロントエンド側からは string として、バックエンドが int64 として扱うことができています。もちろん、これは扱うツールチェーンの解釈にもよります。

また、私自身は ogugu9/typespec-route-linter という Linter を実装しました。
TypeSpec ではパスを設定するための @routenamespace, interface, op に設定できますが、 op 以外はネストが可能です。
ネストすることでルーティングが DRY になりますが、一方で TypeSpec 上の greppability は低下します。 弊チームでは多少の DRY は捨ててフラットなルーティングを推奨しているため、この Linter を作ってみました。

どちらも実装自体はシンプルに収まっており、利用者側で拡張するためのエコシステムが整っている印象でした。

導入後の状況

2025年6月時点で、導入から約1年半が経ち、現在 300 件以上の API が定義されていますが、現状クリティカルな課題は発生していません。
今では OpenAPI を直接読み書きすることは考えられなくなりました。
その間も、OpenAPI 向けの移行ツールの追加、GitHub 上でのシンタックスのサポートが完備、1.0.0 がリリースされるなど、エコシステムはむしろ進化を遂げており、開発体制としては信頼感があります。

一方で、あえて弊チームが唯一直面した課題を挙げてみます。
それは、配列・オブジェクトのパス変数・クエリパラメーターのシリアライズ形式を指定する explode に対する解釈の問題です。
ref. microsoft/typespec/issues #7574

OpenAPI ではクエリパラメータの explode のデフォルトが true であるのに対して、TypeSpec では false としています。
これは RFC 6570 の URI Template に関する仕様で定義される「explode 演算子 * を明示的に付与しなかった場合の挙動」に合わせているとのことでした。

こういった点で OpenAPI の仕様に対する厳密な一致は目指しておらず、TypeSpec としての主張も持っていくようです。
その背景には、おそらく TypeSpec 自身も TypeSpec からのコード生成機能を有していたり、OpenAPI 以外の形式への出力も対応していることもあると推察しています。 (実際、TypeSpec のコード生成機能の内部では uri-template を利用しています。)

今回は explode を明示指定すればよいだけの話でしたが、こういった背景や思想を理解して利用する必要があります。

最後に

開発者体験は、メンバーにとっての直感的な開発体感が全てだと思いますが、その点で TypeSpec は最高と感じています。
すでに OpenAPI でスキーマを書くチームにとって、導入しない理由がなくなったといっても過言ではないでしょう。
この記事を通して TypeSpec がより普及し、エコシステムがさらに進化していくことを期待しています。

皆さんもぜひ導入を検討してみてください。