債権・請求書ドメインでエンジニアをやっている jaxx です。 好きな ESP32 デバイスは M5Stack ATOMS3 Lite です。
freee請求書では、Public API だけでなく、内部的なAPIも含めると複数あり、最も多いAPI群は、生成後のOpenAPIが 33,000行ありましたが、今回 API 定義のソースを OpenAPI (YAML) から TypeSpec へ全面的に移行しました。 今回はその話を備忘録としてエントリーにしたいと思います。
なぜ既存の技術スタックを変更するのか
freee請求書はスキーマ駆動開発を採用していて、OpenAPI を起点にバックエンドのリクエスト検証とフロントエンドの API クライアント・型・モックを生成しています。プロダクトは請求書・見積書・納品書・発注書・領収書・支払通知書という 6 つの帳票種別があり、それぞれがほぼ同型の API 群 (一覧・作成・更新・PDF・メール送付・ステータス変更など) を持ちます。OpenAPI には同型の API 群を束ねる抽象化手段がないため ERB テンプレートを用いて帳票種別の openapi.yml を展開する独自実装があり、ここをエンジニアやAIにとって認知負荷の低い、標準的な技術スタックに置き換えたいなと思っていました。
なぜ TypeSpec にするのか
TypeSpec は Microsoft 製の API 定義言語で、TypeScript に似た構文から OpenAPI を出力できます。freee 社内では支出管理チームが先行導入していて、その経緯は OpenAPI ではなく TypeSpec を読み書きするスキーマ駆動開発 にまとまっています。
上記から1年経ってプラスでメリットだなと思っているのが
- コーディングエージェントでは苦手な再現性や速度の問題をコンパイラとジェネレーターに寄せることで決定論的な処理にできること
- TypeScript ライクな構文によって記述量を減らすことによる生成やレビューコストの削減になること
の2点が挙げられます。債権請求書チームとして freee請求書への TypeSpec の導入を決めました。
段階的な移行戦略
移行にあたって制約が1つありました。freee請求書では、既存の OpenAPI 上で3〜5チームが同時に機能改修を行っています。移行中の API スキーマの凍結は難しく、機能開発に影響なく段階的に移行し、各フェーズで壊れていないことを機械的に確認できる必要がありました。この移行手法が、本記事で一番伝えたい部分です。
まずは tsp-openapi3 という移行ツールが TypeSpec 公式から出ているので機械的に変換して、変換する際のオペミスが無いようにしました。そして後処理で正規化した openapi.yml が移行前と等価であることを diff で確認して切り替えていきました。generics への集約のような抽象化は人間がやりますが、これも diff が出なければ API の形は変わっていないという、同じ検証で確認できます。
段階移行なので、生成フローは「移行済みの API」と「未移行の API」が共存する前提で設計しました。

肝は root.yml と tsp-converted.yaml です。root.yml は全エンドポイントを $ref で列挙したマニフェストになっていて、TypeSpec のコンパイル結果と既存 YAML という複数の YAML を 1 つの openapi.yml に bundle します。API を移行するたびに、その path の参照先を YAML から TypeSpec のコンパイル結果へ張り替える。移行が進むほど YAML 側への参照は減っていき、ゼロになった時点でこの共存機構ごと撤去しました。
また冒頭で触れた「6 帳票 × 同型 API」は、TypeSpec の generics で正面から解決できました。帳票共通の API 群を generic interface として 1 箇所に定義します。
@doc("帳票種別ごとの private API path 集合") interface ReportPaths<Resource, CreateReq, IndexResp, AccDocResp> { @get index(...IndexQueryParams): IndexResp; @post create(@body body: CreateReq): Resource; @put @route("/{report_id}") update(@path report_id: integer, @body body: CreateReq): Resource; @post @route("/{report_id}/deliveries/email") deliverEmail( @path report_id: integer, @body body: DeliveryEmailParams, ): Resource; // ... PDF、ステータス変更、操作履歴など約 30 エンドポイント }
各帳票はこれを 1 回インスタンス化 (instantiate) するだけです。
@route("/api/p/reports/receipts") namespace ReportsReceipts { interface ReportsReceipts extends ReportPaths< Receipt, ReceiptCreateRequest, ReceiptIndexResponse, ReceiptAccountingDocShowResponse > {} }
ERB テンプレートで YAML を展開していた独自機構が、言語機能としての generics に置き換わりました。帳票横断の API 変更は generic interface の 1 箇所を直せば 6 帳票に波及します。帳票の paths は 6 帳票で約 300 エンドポイントあり、抽象化せずベタ書きすると openapi.yml 上で約 8,500 行になりますが、generic interface を使えば共通定義 245 行 + 各帳票の継承で、ソースは約 1,300 行 (6 分の 1 以下) に収まります。
移行手順を Agent Skills に残す
移行作業そのものは、大部分を AI コーディングエージェントで実施しています。やり方はシンプルで、移行手順を Agent Skill に書き、チケット単位でエージェントに実装から PR 作成、CI の監視までを任せる、という運用です。
Agent Skill には移行中に踏んだエラーを蓄積していきました。たとえば OpenAPI と TypeSpec の型対応表、生成クライアントのフック名を固定するために operationId の明示が必須であること、参照型のフィールドに @doc() を付けると allOf が生成されて nullable と誤解釈されること。人間のチームでいう「経験者の暗黙知」を Agent Skills に必ず追記していくようにしていました。
また検証は次の 2 つの観点を見て進めました
- operationId の一致。生成されるフック名が変わっていないこと
- 同名スキーマの衝突検知。TypeSpec 由来と YAML 由来で同名のスキーマは、内容が完全一致すれば 1 つにマージされ、不一致なら
_1サフィックス付きの別スキーマになる。_1が生成物に出てきたらズレの可能性がある
実際、独立した小さい API ファイルの移行は、この検証と openapi.yml の diff 確認で淡々と進めることが出来ました。
移行で最も難しかった部分
ERB で展開される帳票種別の部分は、他のファイルと同じ「YAML を 1 ファイルずつ TypeSpec に変換する」手順では流せませんでした。
難しさは、ステータス変更やメール送付といった帳票間の共有スキーマの移行という1点です。 移行期間中は移行済み帳票 (TypeSpec) と未移行帳票 (YAML) が同じ共有スキーマを参照し続けるからです。
ここは消去法で選択肢を潰していきました
ボツになった案
- 移行済み帳票 (TypeSpec) と未移行帳票 (YAML) に同じ定義を置く案:
- 内容が完全一致しないと別物扱いになる。emitter は
type: objectや description を勝手に足すので手書き YAML と一致せず、DeliverStatusOnly_1のような重複となるので良くない
- 内容が完全一致しないと別物扱いになる。emitter は
- 共有スキーマだけ先に TypeSpec 化する案:
- generic interface が参照するモデルは、interface をインスタンス化するまで生成されない。1 帳票も切り替えていない段階では共有スキーマが出力されず、未移行帳票からの
$refが解決できず、複数 YAML を 1 つの openapi.yml に bundle できない
- generic interface が参照するモデルは、interface をインスタンス化するまで生成されない。1 帳票も切り替えていない段階では共有スキーマが出力されず、未移行帳票からの
- namespace に入れて名前の衝突を避ける案:
- bundle 自体は通る。だが emit 名が
DeliverStatusOnlyからReportsDeliverStatusOnlyに変わり、これが一番危険だった
- bundle 自体は通る。だが emit 名が
最後の案がなぜ危険か。emit 名 (生成される OpenAPI 上のスキーマ名) は、生成クライアントの関数の引数名・型名に直結しているからです。生成クライアントはこうなります。
export const reportsReceiptsDeliverStatus = async (reportId: number, - deliverStatusOnly: DeliverStatusOnly, options?: RequestInit): Promise<DeliverStatusOnly> => { + reportsDeliverStatusOnly: ReportsDeliverStatusOnly, options?: RequestInit): Promise<ReportsDeliverStatusOnly> => {
API のパスもリクエストの中身も変わっていないのに、引数名が変わっています。呼び出し側は 6 帳票共通のコードなので、この rename は実行時に値が渡らなくなる、つまりプロダクションに影響します。
しかも検出が難しい。移行期間中は新旧の生成型が混在するため、codegen 境界の型の食い違いを「互換吸収」の as never キャストで埋めたくなりますが、これが引数名の不一致という本物の型エラーまで握りつぶします。序盤の移行で頼っていた検証は operationId の一致と _1 の検知でしたが、emit 名の変化はそのどちらにも引っかかりません。
採用した案
ではどうやったかでいうと TypeSpec 側の 1 箇所だけに置き、YAML 側はそこを $ref で参照することで single-source となるようにしました。
- 共有スキーマは namespace に入れず、元の名前のままファイルスコープで宣言する。これで emit 名が
DeliverStatusOnlyのまま保たれる - YAML 側の定義は削除し、ERB や未移行帳票からの参照は tsp-converted.yaml への $ref に張り替える
- generics の emit 遅延があるため独立 PR にはできず、最初の帳票の切り替えと同じ PR で行う
定義が 1 箇所になれば、重複スキーマ (_1) も emit 名の揺れも構造的に起きません。
この移行を通じての学び
この移行を通じての学びは 2 つあります。
- 既存 API を壊さず移行するため「OpenAPI を中間成果物に残し、diff で等価であることを確認できる」設計にしたこと。TypeSpec をどう書き換えても、最終的な openapi.yml に diff が出なければ API の形は変わらない。この機械的な確認があったから、複数チームが開発を続けるなかでも少しずつ安全に移すことが出来ました。
- TypeSpec 移行によって、ベタ書きすると約 8,500 行あった openapi.yml が generics に置き換わり約 1,300 行 (6 分の 1 以下) になり記述量が少なく、人やAIにとっても読み書きしやすく、独自実装ではなく標準的な技術スタックにすることが出来ました。
freee請求書の TypeSpec 移行はこれで完了し、API 定義の変更はすべて TypeSpec を起点に運用されています。 みなさんのプロダクトでも検討の材料になれば幸いです。

