READYFOR Tech Blog

READYFOR のエンジニアブログ

TypeScript の型生成における OpenAPI Generator のハマりどころ

こんにちは。READYFOR でフロントエンドエンジニアとして働いている菅原(@kotarella1110)です!

嬉しいことに、READYFOR のプロダクト開発組織は急拡大中で、正社員の人数は 2019年7月時点では8名でしたが2021年4月現在は29名になりました 🎉
その反面、組織が大きくなってくると「我々はどこに向かってるんだっけ?」「あの人、最近はどんなことに取り組んでいるんだろう?」といった「見えないこと」が増えてきます🤔
この「見えないこと」を減らすために、READYFOR では毎月「プロダクト開発本部会」を開催しています。
プロダクト開発本部会では、必要な全体周知(組織やプロダクトの方針等)や月替わりプレゼンテーションを行っています。
本記事はこのプロダクト開発本部会の月替わりプレゼンテーションで発表した内容になります。

はじめに

以前 READYFOR で初主催となるフロントエンドエンジニア向けの勉強会【実践!フロントエンド分離戦略】を開催しました。 私は「OpenAPI Generator と TypeScript による型安全なスキーマ駆動開発」のタイトルで登壇し、スキーマ駆動開発とそのメリット、活用しているツールについて紹介しました。

speakerdeck.com logmi.jp

この場で OpenAPI Generator のハマりどころについても紹介したですが、語りきれなかったハマりどころが多々あります。 そのため、この場では語りきれなかったハマりどころについてコードベースで詳しく紹介できればと思います。

OpenAPI Generator について

OpenAPI Generator について簡単に説明すると、OpenAPI のスキーマ定義から API クライアントやモデル、リクエスト・レスポンスの型などを生成することができます。

docker run --rm -v "${PWD}:/local" \
  openapitools/openapi-generator-cli generate \
  -i /local/openapi.yaml \
  -g typescript-fetch \
  -o /local/dist

READYFOR ではフロント側のジェネレーターとして typescript-fetch を選択しています。 また、生成されたコードのうち API クライアントは使用せずに型定義のみを使用しています。

※ typescript-fetch 以外にも typescript-axios や typescript-angular など TypeScript のジェネレーターは複数存在します。

OpenAPI Generator のハマりどころ

それでは以下の環境でのハマりどころについて紹介します。

  • OpenAPI Generator のバージョン:5.1.1
  • ジェネレーター:typescript-fetch

enum 定義が Enum で生成される

スキーマで定義した enum は以下のように Enum で生成されます。

# スキーマ定義
components:
  schemas:
    Example:
      type: object
      properties:
        property:
          type: string
          enum:
            - "00"
            - "01"
            - "02"
// 生成される型
export interface Example {
    property?: ExamplePropertyEnum;
}
export enum ExamplePropertyEnum {
    _00 = '00',
    _01 = '01',
    _02 = '02'
}

スキーマで定義した enum が Enum で生成されるのはごく自然なことですが、 Enum よりも Union 型を好む TypeScript ユーザーがほとんどです。

www.kabuku.co.jp engineering.linecorp.com

そのため、以下のように Enum ではなく Union 型に生成するようなオプションがあると嬉しいですが、そのようなオプションは存在しません。

// 期待する型
export interface Example {
    property?: ExamplePropertyEnum;
}
export type ExamplePropertyEnum = '00' | '01' | '02';

ジェネレーターとして typescript-fetch を選択し続ける場合は、Union 型に生成するオプションが追加されることを期待して待つしかないでしょう。

補足

oneOf を使用することで Union 型に生成することは可能ですが、リテラルな Union 型を生成することはできません。 これは OpenAPI スキーマにリテラル型という概念がないためです。

# スキーマ定義(リテラル型は定義できない)
components:
  schemas:
    Example:
      type: object
      properties:
        property:
          oneOf:
            - type: 'foo' # エラー
            - type: 1     # エラー

したがって、oneOf は Enum の代替手段にはなりません。

# スキーマ定義(プリミティブ型は定義できる)
components:
  schemas:
    Example:
      type: object
      properties:
        property:
          oneOf:
            - type: string
            - type: number
// 生成される型
export interface Example {
    property?: string | number;
}

oneOf を使用すると型エラーが発生する

スキーマ定義で oneOf を使用すると基本的に正しい型が生成されますが、生成されるコード側に誤りがあるため型エラーが発生してしまいます。

# スキーマ定義
components:
  schemas:
    Example:
      type: object
      properties:
        property:
          oneOf:
            - type: string
            - type: number
// 生成される型
export interface Example {
    property?: string | number;
}

export function ExampleFromJSONTyped(json: any, ignoreDiscriminator: boolean): Example {
    if ((json === undefined) || (json === null)) {
        return json;
    }
    return {
        
        'oneOf': !exists(json, 'oneOf') ? undefined : string | numberFromJSON(json['oneOf']), // ❌ 型エラー
    };
}

これは typescript-fetch のバグと予想されます。
READYFOR では、型エラーが発生するファイルの型チェックをしないようにしています。 具体的には sed コマンドで型エラーが発生するファイルに // @ts-nocheck のコメントを追加するようにコード生成のスクリプトを組んでいます。

sed -i '' '3s/^/\/\/ @ts-nocheck\n/' src/dist/api/models/XXXXX.ts

ただし、生成されたコードのうち API クライアントを使用しているユーザーの場合は oneOf の使用を諦めるか・別の対策が必要になります。

oneOf を使用したプロパティ付きの object 定義が object 型で生成される

スキーマ定義で oneOf を使用したプロパティ付きの object 定義が object 型で生成されてしまいます。

# スキーマ定義
components:
  schemas:
    Example:
      type: object
      properties:
        property:
          oneOf:
            - type: object
              properties:
                hoge:
                  type: string
                fuga:
                  type: number
            - type: object
              properties:
                foo:
                  type: string
                bar:
                  type: number

このスキーマ定義は「property というプロパティの値が hogefuga というプロパティを持つオブジェクト、もしくは foobar というプロパティを持つオブジェクト」という意味合いのため、以下のような型が生成されることを期待します。

// 期待する型
export interface Example {
  property?:
    | {
        hoge?: string;
        fuga?: number;
      }
    | {
        foo?: string;
        bar?: number;
      };
}

しかし、実際には以下のような型が生成されてしまいます。

// 生成される型
export interface Example {
    property?: object;
}

以下のように components/schemas を使用して、hogefuga というプロパティを持つオブジェクトと foobar というプロパティを持つオブジェクトそれぞれ定義することで解決することができます。

# スキーマ定義
components:
  schemas:
    Example:
      type: object
      properties:
        property:
          oneOf:
            - $ref: "#/components/schemas/HogeFugaObject"
            - $ref: "#/components/schemas/FooBarObject"
    HogeFugaObject:
      type: object
      properties:
        hoge:
          type: string
        fuga:
          type: number
    FooBarObject:
      type: object
      properties:
        foo:
          type: string
        bar:
          type: number
// 生成される型
export interface Example {
    property?: HogeFugaObject | FooBarObject;
}
export interface HogeFugaObject {
    hoge?: string;
    fuga?: number;
}
export interface FooBarObject {
    foo?: string;
    bar?: number;
}

Nullable な enum 定義が Nullable 型で生成されない

スキーマ定義で nullable: true を指定すると Nullable な値を定義することができます。

# スキーマ定義
components:
  schemas:
    Example:
      type: object
      properties:
        property:
          type: string
          enum:
            - "00"
            - "01"
            - "02"
          nullable: true

このスキーマ定義は、以下のように null を含む Union 型で生成されることを期待します。

// 期待する型
export interface Example { 
    property?: ExamplePropertyEnum | null;
}
export enum ExamplePropertyEnum {
    _00 = '00',
    _01 = '01',
    _02 = '02'
}

しかし、実際には以下のような型が生成されてしまいます。

// 生成される型
export interface Example {
    property?: ExamplePropertyEnum;
}
export enum ExamplePropertyEnum {
    _00 = '00',
    _01 = '01',
    _02 = '02'
}

以下のように components/schemas を使用して、nullable な enum を定義することで解決することができます。

# スキーマ定義
components:
  schemas:
    Example:
      type: object
      properties:
        property:
          $ref: "#/components/schemas/NullableEnum"
    NullableEnum:
      type: string
      enum:
        - "00"
        - "01"
        - "02"
      nullable: true
// 生成される型
export interface Example { 
    property?: NullableEnum | null;
}
export enum NullableEnum {
    _00 = '00',
    _01 = '01',
    _02 = '02'
}

date/date-time フォーマットの string 定義が Date 型で生成される

OpenAPI では値に日付やメールアドレス、UUID などを表現するためにフォーマットを指定することができます。

  • date フォーマット:ISO8601拡張形式(例:2020-01-31)
  • date-time フォーマット:タイムゾーン指定子付き ISO8601 形式(例:2020-01-31T23:59:59+09:00)

スキーマ定義で date/date-time フォーマットの string 定義は Date 型で生成されてしまいます。

# スキーマ定義
components:
  schemas:
    Example:
      type: object
      properties:
        date:
          type: string
          format: date
        dateTime:
          type: string
          format: date-time
// 生成される型
export interface Example {
    date?: Date;
    dateTime?: Date;
}

API からのレスポンスデータは string 型なのに何故 Data 型で生成されてしまうのでしょうか?

# レスポンス
{
  "date": "2020-01-31",
  "dateTime": "2020-01-31T23:59:59+09:00"
}
// レスポンスの型
export interface Example {
    date?: string;
    dateTime?: string;
}

これは typescript-fetch のバグではなく意図されたものです。 コードを生成すると、型定義とあわせてJSON レスポンスデータを加工する以下のような関数も生成されます。この関数で date/date-time フォーマットのプロパティの値を string から Date に変換しています。typescript-fetch では API クライアントがこの関数を呼び出して、いい感じに加工した JSON レスポンスデータをユーザーに返すような設計になっています。

export function ExampleFromJSONTyped(json: any, ignoreDiscriminator: boolean): Example {
    if ((json === undefined) || (json === null)) {
        return json;
    }
    return {
        
        'date': !exists(json, 'date') ? undefined : (new Date(json['date'])),
        'dateTime': !exists(json, 'dateTime') ? undefined : (new Date(json['dateTime'])),
    };
}

生成された API クライアントを使用しているユーザーにとっては問題ありませんが、 READYFOR のように型定義のみを使用しているユーザーの場合は、 実レスポンスデータの型と生成される型が不一致のため問題があります。

したがって、Date 型ではなく string 型で生成するには、 date/date-time フォーマットを諦めるしかありません。

# スキーマ定義
components:
  schemas:
    Example:
      type: object
      properties:
        date:
          type: string
        dateTime:
          type: string
// 生成される型
export interface Example {
    date?: string;
    dateTime?: string;
}

ハマってきて・・・

  • 期待する型が生成されない場合は、components/schemas の使用を試してみるのが良さそう
  • 生成されるコードを考慮したフロントに優しい OpenAPI 設計が必要そう
  • ハマりどころを踏まえ、OpenAPI 設計規約を作成していくのが良さそう
  • OpenAPI Generator へのコントリビュートチャンスが多々ある(Java で記述)
  • typescript-fetch 以外のジェネレーターも検討できそう
    • READYFOR では生成されたコードのうち API クライアントは使用せずに型定義のみを使用しているためジェネレーターに依存しない
    • 期待する型が生成され、ハマりどころが少ないジェネレーターがベスト
    • ジェネレーターを自作してしまった方が楽かも?

typescript-fetch 以外のジェネレーターも検討できそう

TypeScript のジェネレーターを実際に比較してみました!

TypeScript のジェネレーター比較

TypeScript のジェネレーター比較は以下の通りです。

型生成の観点から READYFOR にとって最も最適なジェネレーターは typescript-angular であるということが分かりました 👀
現在 READYFOR では typescript-fetch から typescript-angular の移行を視野に入れて取り組んでいます。

typescript-angular > typescript-axios > typescript-fetch = typescript-redux-query

typescript-fetch typescript-angular typescript-axios typescript-redux-query
enum 定義で生成される型 Enum 型 Union 型 Enum 型 Enum 型
date 定義で生成される型 Date 型 string 型 string 型 Date 型
date-time 定義で生成される型 Date 型 string 型 string 型 Date 型
oneOf 定義で型エラーが発生するか? 発生する 発生しない 発生しない 発生する
oneOf を使用したプロパティ付きの object 定義で正しい型が生成されるか? 正しくない ※ 正しくない ※ 正しくない ※ 正しくない ※
Nullable な enum 定義で Nullable 型になるか? ならない ※ なる ならない ※ ならない ※

※ 先に説明しましたが、components/schemas を使用することで解決できます。

比較に使用したコードはこちらをご覧ください。 github.com

最後に

スキーマ駆動開発を実践してきての「TypeScript の型生成における OpenAPI Generator のハマりどころ」について紹介しました。
何も意識せずに OpenAPI スキーマを定義して、OpenAPI Generator でコードを生成すると必ずハマります 🕳
この記事が自分と同じようにハマった方への参考になれば幸いです!

最後に、本発表の様子が YouTube にアップロードされているため、よろしければご覧ください 🙇‍♂️

www.youtube.com

それでは!