🐷

OpenAPIをTypeSpecで書き換えたら型定義が崩壊した話

に公開

はじめに

モダンフロントエンドでサービス開発を進めていると、APIや戻り値の型定義が必ず発生します。

しかし、開発が進みサービスが複雑化していくと、定義ファイルがどんどん大きくなる問題が待っています。特にYAMLで書かれた定義ファイルは、はじめこそ読みやすいものの、肥大化に伴って編集するのも読み下すのも大変になっていきます。

分割するなどの工夫はありますが、記載量が多いことに変わりはなく、時にはサンプルの戻り値が足りないなどの弊害を引き起こすこともあります。

こんな状況、つらくないですか?

この記事ではOpenAPIの代替手段であるTypeSpecを紹介するとともに、TypeSpecで書き換えようとしたときに遭遇した困りごとを記録しておこうと思います。

TL;DR

  • TypeSpecはOpenAPIをTypeScript風の構文で書ける言語
  • aliasを使うと型に意味のある名前をつけられる
  • しかし、TypeScript出力時にaliasは展開されてプリミティブ型になる
  • これはTypeSpecの仕様であり、OpenAPIでも同様の問題が起きる
  • 現状の回避策はscalarの活用や生成後の手動対応など

TypeSpecとは

OpenAPIの課題

OpenAPIでスキーマを定義する際、以下のような課題に直面することがあります。

YAMLが冗長

単純な型定義でもネストが深く、行数が膨らみます。プロパティを1つ追加するだけでも複数行の記述が必要になり、スキーマ全体の見通しが悪くなりがちです。

型安全でない

YAMLは文字列ベースのため、typoに気づきにくく、エディタの補完も限定的です。tpye: stringと書いてもエラーにならず、実行時まで問題が発覚しないこともあります。

再利用性が低い

$refで他のスキーマを参照できますが、記述が煩雑で可読性が落ちます。共通の型を抽出しようとすると、ファイル分割やパス管理の手間が増えます。

スキーマとコードが乖離しやすい

手書きのYAMLとアプリケーションコードを同期させる作業は手動になりがちです。どちらかを更新し忘れると、ドキュメントと実装が乖離します。

学習コストと認知負荷

OpenAPI仕様自体が巨大で、正しく書くためには仕様の詳細を把握する必要があります。nullablerequiredの組み合わせなど、直感的でない挙動も多いです。

TypeSpecが解決すること

TypeSpecはこれらの課題に対して、以下のようなアプローチで解決を図ります。

簡潔な構文

TypeScript風の構文で記述量を大幅に削減できます。YAMLの冗長なネストから解放され、スキーマの意図が明確になります。

コンパイル時の型チェック

専用言語として設計されているため、コンパイル時にエラーを検出できます。typoや型の不整合を早期に発見できます。

自然な再利用

modelaliasを使って、自然な形で型を再利用できます。TypeScriptを書く感覚で共通型を抽出できるため、学習コストも低いです。

Single Source of Truth

TypeSpecからOpenAPI、TypeScript、その他の形式を自動生成できます。スキーマ定義を一元管理することで、ドキュメントとコードの乖離を防ぎます。

抽象度の高い記述

OpenAPI仕様の詳細を知らなくても、直感的にスキーマを書けます。複雑な仕様はTypeSpecが吸収してくれます。

コード比較

同じスキーマをOpenAPIとTypeSpecで記述した場合の違いを見てみましょう。

OpenAPI(YAML)

components:
  schemas:
    User:
      type: object
      required:
        - id
        - name
      properties:
        id:
          type: integer
          format: int64
        name:
          type: string
        email:
          type: string
          format: email

TypeSpec

model User {
  id: int64;
  name: string;
  email?: string;
}

TypeSpecでは約4分の1の行数で同じスキーマを表現できています。


TypeSpecの基本的な書き方

TypeSpecはTypeScriptに似た構文を持つため、TypeScriptユーザーであれば直感的に書き始められます。ここでは最小限の構文を紹介します。

model(型定義)

TypeSpecの中心となるのがmodelです。OpenAPIのschemasに相当します。

model User {
  id: int64;
  name: string;
  email: string;
}

?をつけるとオプショナル(必須でない)プロパティになります。

model User {
  id: int64;
  name: string;
  email?: string;  // オプショナル
}

基本的な型

TypeSpecには以下のような組み込み型があります。

TypeSpec OpenAPIでの対応
string type: string
int32 type: integer, format: int32
int64 type: integer, format: int64
float32 type: number, format: float
float64 type: number, format: double
boolean type: boolean
bytes type: string, format: byte
plainDate type: string, format: date
utcDateTime type: string, format: date-time

配列

配列は[]で表現します。

model Team {
  name: string;
  members: User[];  // Userの配列
}

enum(列挙型)

固定の選択肢を定義する場合はenumを使います。

enum Status {
  Active,
  Inactive,
  Pending,
}

model User {
  id: int64;
  name: string;
  status: Status;
}

値を明示的に指定することもできます。

enum Status {
  Active: "active",
  Inactive: "inactive",
  Pending: "pending",
}

alias(型エイリアス)

既存の型に別名をつける場合はaliasを使います。

alias UserId = int64;
alias Email = string;

model User {
  id: UserId;
  email: Email;
}

union(ユニオン型)

複数の型のいずれかを表現する場合は|を使います。

alias Id = string | int64;

model Resource {
  id: Id;
}

名前付きユニオンも定義できます。

union PaymentMethod {
  creditCard: CreditCard,
  bankTransfer: BankTransfer,
  cash: "cash",
}

デコレーター

@で始まるデコレーターを使って、メタ情報を付与できます。

@doc("ユーザーを表すモデル")
model User {
  @key
  id: int64;

  @minLength(1)
  @maxLength(100)
  name: string;

  @format("email")
  email?: string;
}

よく使うデコレーターは以下のとおりです。

デコレーター 用途
@doc("...") ドキュメントコメント
@summary("...") 概要
@key 主キー
@format("...") フォーマット指定
@minLength(n) 最小文字数
@maxLength(n) 最大文字数
@minValue(n) 最小値
@maxValue(n) 最大値
@pattern("...") 正規表現パターン
@example(...) 例示

namespace(名前空間)

スキーマをグループ化する場合はnamespaceを使います。

namespace MyApi {
  model User {
    id: int64;
    name: string;
  }

  model Post {
    id: int64;
    title: string;
    author: User;
  }
}

import(インポート)

他のファイルやライブラリを読み込む場合はimportを使います。

import "@typespec/http";
import "@typespec/openapi3";

import "./models.tsp";

APIエンドポイントの定義

HTTPライブラリを使うと、エンドポイントも定義できます。

import "@typespec/http";

using Http;

@route("/users")
namespace Users {
  @get
  op list(): User[];

  @get
  op read(@path id: int64): User;

  @post
  op create(@body user: User): User;

  @put
  op update(@path id: int64, @body user: User): User;

  @delete
  op delete(@path id: int64): void;
}

OpenAPIとの対応まとめ

概念 OpenAPI TypeSpec
型定義 components.schemas model
列挙型 enum enum
参照 $ref 型名をそのまま使用
必須/オプション required ?の有無
説明 description @doc()
エンドポイント paths @route + op

TypeSpecを導入してみたら、困ったことが起きた

TypeSpecの便利さに魅力を感じ、早速プロジェクトに導入してみました。しかし、TypeScriptの型定義を生成してみると、期待と異なる出力に遭遇しました。

期待した出力と実際の出力

書いたTypeSpecコード

ユーザーIDやメールアドレスに意味のある名前をつけたくて、aliasを使って定義しました。

alias UserId = string;
alias Email = string;
alias Timestamp = utcDateTime;

model User {
  id: UserId;
  email: Email;
  createdAt: Timestamp;
  updatedAt: Timestamp;
}

model Post {
  id: string;
  authorId: UserId;
  createdAt: Timestamp;
}

期待した出力(TypeScript)

type UserId = string;
type Email = string;
type Timestamp = Date;

interface User {
  id: UserId;
  email: Email;
  createdAt: Timestamp;
  updatedAt: Timestamp;
}

interface Post {
  id: string;
  authorId: UserId;
  createdAt: Timestamp;
}

UserIdEmailといった型エイリアスがそのまま残り、型の意味が明確に伝わる出力を期待していました。

実際の出力(TypeScript)

interface User {
  id: string;
  email: string;
  createdAt: Date;
  updatedAt: Date;
}

interface Post {
  id: string;
  authorId: string;
  createdAt: Date;
}

aliasで定義した型がすべて展開され、プリミティブ型に置き換わってしまいました。UserIdEmailも、ただのstringになっています。


なぜこの問題が起きるのか

TypeSpecにおけるaliasの扱い

TypeSpecのaliasは、TypeScriptのtypeとは根本的に異なる概念です。

TypeSpecにおけるaliasは、コンパイル時に解決される「名前の別名」であり、型システム上の独立した型ではありません。公式ドキュメントでも以下のように説明されています。

Aliases are syntax nodes that are erased at the point of use.
(aliasは使用時に消去される構文ノードである)

つまり、aliasはTypeSpec内での記述を簡潔にするための機能であり、出力先の型システムに引き継がれることを意図していません。

TypeSpecの型の種類

種類 出力時の扱い 用途
model 独立した型として出力 オブジェクト型の定義
enum 独立した型として出力 列挙型の定義
union 条件により出力 複合型の定義
alias 展開されて消える 記述の簡略化

aliasだけが出力時に「消える」存在であることがわかります。

エミッター(TypeScript変換)の内部挙動

TypeSpecからTypeScriptを生成するエミッター(@typespec/tsや各種エミッター)は、以下のような処理を行います。

  1. TypeSpecのASTを解析
  2. modelenumを対応するTypeScriptの型に変換
  3. aliasは参照先の型に解決(展開)
  4. TypeScriptコードを出力

この3番目のステップで、aliasは単なる「参照」として扱われ、参照先の型で置き換えられます。

TypeSpec AST          →  解決後           →  TypeScript出力
─────────────────────────────────────────────────────────
alias UserId = string →  string          →  (出力なし)
id: UserId            →  id: string      →  id: string

エミッターにとってaliasは「コンパイル時に解決すべき参照」であり、「出力すべき型定義」ではないのです。

OpenAPIでも同じ問題は起きる?

結論から言うと、OpenAPIでも同様の問題は起きます。

OpenAPIで型エイリアスのような概念を表現するには、$refを使ってスキーマを参照する方法があります。

components:
  schemas:
    UserId:
      type: string
    User:
      type: object
      properties:
        id:
          $ref: '#/components/schemas/UserId'

しかし、多くのコードジェネレーターは$refを解決し、インライン展開してしまいます。これはTypeSpecのaliasと同じ問題です。

OpenAPIとTypeSpecの比較

観点 OpenAPI TypeSpec
エイリアス的な定義 $refでスキーマ参照 aliasで型に別名
生成時の扱い ツールにより展開されることが多い 常に展開される
回避策 x-拡張属性で独自対応 modelでラップ

つまり、この問題はTypeSpec固有ではなく、スキーマ定義言語からコード生成する際の構造的な課題と言えます。


aliasが展開されると何が困るのか

「動くなら問題ないのでは?」と思うかもしれません。しかし、実際の開発では以下のような困りごとが発生します。

可読性の低下

// aliasが残っている場合
function findUser(id: UserId): User { ... }

// aliasが展開された場合
function findUser(id: string): User { ... }

後者では、引数idが「何のID」なのかが型から読み取れません。PostIdを渡してもOrderIdを渡してもコンパイルは通ってしまいます。

型の意図が伝わらない

// 展開後のコード
interface User {
  id: string;
  email: string;
  externalId: string;
  referralCode: string;
}

すべてがstringになると、各プロパティがどのような制約や意味を持つのか、型だけでは判断できません。コードレビューやドキュメントに頼る必要が出てきます。

変更時の影響範囲が見えにくい

UserIdの型をstringからUUID型に変更したい場合を考えます。

aliasが残っていれば、型定義を1箇所変更するだけで、すべてのUserIdが影響を受けることが明確です。

// 変更前
type UserId = string;

// 変更後 - 影響範囲が型システムで追跡可能
type UserId = UUID;

しかし、展開されていると、すべてのstringを手動で探して変更する必要があります。そして、そのstringUserIdなのか別の文字列なのか、判断がつきません。

Branded Typeが使えない

TypeScriptでは、型安全性を高めるためにBranded Type(またはOpaque Type)というパターンがあります。

type UserId = string & { readonly __brand: unique symbol };
type PostId = string & { readonly __brand: unique symbol };

// これはコンパイルエラーになる(意図した動作)
const userId: UserId = "user-1" as UserId;
const postId: PostId = userId; // Error!

aliasが展開されてしまうと、このようなパターンを適用する余地がなくなります。


現状の解決策

方法1: modelでラップする

aliasの代わりに、単一プロパティを持つmodelとして定義します。

// Before: alias(展開される)
alias UserId = string;

// After: model(展開されない)
model UserId {
  value: string;
}

ただし、この方法には欠点があります。

// 生成されるTypeScript
interface UserId {
  value: string;
}

interface User {
  id: UserId;  // { value: string } になる
}

// 使用時に .value が必要
const userId = user.id.value;

スキーマとしては正確ですが、使い勝手が悪くなります。

方法2: scalar型を使う

TypeSpecのscalarを使うと、プリミティブ型を拡張した独自の型を定義できます。

@format("uuid")
scalar UserId extends string;

@format("email")
scalar Email extends string;

model User {
  id: UserId;
  email: Email;
}

scalarは一部のエミッターでは独自の型として出力される可能性がありますが、TypeScriptエミッターでは展開されることが多いです。ただし、OpenAPI出力時にはformat情報が保持されるメリットがあります。

方法3: 生成後に手動で型エイリアスを追加する

エミッターの出力を直接使わず、生成されたコードをベースに手動で型エイリアスを追加するファイルを作成します。

// generated/types.ts(自動生成)
export interface User {
  id: string;
  email: string;
}

// types.ts(手動管理)
import { User as GeneratedUser } from './generated/types';

export type UserId = string;
export type Email = string;

export interface User extends GeneratedUser {
  id: UserId;
  email: Email;
}

この方法は手間がかかりますが、既存のワークフローを大きく変えずに対応できます。

方法4: カスタムエミッターを作成する

TypeSpecはエミッターをカスタマイズできるため、aliasを展開せずに出力する独自エミッターを作成できます。

// 概念的なコード
export function emitAlias(alias: Alias) {
  return `type ${alias.name} = ${emitType(alias.type)};`;
}

ただし、エミッターの作成・保守コストは小さくありません。

今後の展望

この問題はTypeSpecのGitHubリポジトリでも議論されています。

コミュニティからの要望も多く、将来的にはエミッターのオプションとしてaliasの保持が選択できるようになる可能性があります。

現時点では、以下の方針が現実的です。

  1. aliasの展開を受け入れる: 型の意味はドキュメントやコメントで補う
  2. scalarを活用する: OpenAPI出力ではformat情報が残る
  3. modelでラップ: 型安全性を優先する場合
  4. 後処理スクリプト: 生成後に型エイリアスを追加する

プロジェクトの要件に応じて、適切な方法を選択してください。


まとめ

TypeSpecはOpenAPIの冗長さを解消し、TypeScript風の簡潔な構文でスキーマを定義できる強力なツールです。

しかし、aliasがTypeScript出力時に展開されてしまう点には注意が必要です。これはTypeSpecの設計上の仕様であり、OpenAPIでも同様の問題が発生します。

現状では完璧な解決策はありませんが、scalarの活用や生成後の手動対応など、プロジェクトに合った方法で対処できます。

TypeSpecは活発に開発が続いているプロジェクトです。この問題についても将来的な改善が期待できるため、引き続きウォッチしていきたいと思います。

株式会社ZOZO

Discussion