🤹‍♀️

openapi-typescriptとZodで任意フィールドの型エラーの対処

2025/02/02に公開

OpenAPI、Swaggerから名前が変わってだいぶ良い印象になったなと謎の感想を持っている@zaruです、こんにちは。TypeScriptでOpenAPIを扱うなら openapi-typescript / openapi-fetchライブラリが圧倒的に良いと思いずっと使っています。また、バリデーションはいくつかありますがZodを使い続けています。

今回は「OpenAPIの任意フィールドが、openapi-typescriptで生成したOpenAPIの型情報と、Zodスキーマの型情報が不一致で型エラーになる」症状の対処法を紹介します。前提として tsconfig.jsonexactOptionalPropertyTypes: true になっているときに派生 します。

OpenAPI定義例

JSONでデータを受け取るPOST APIの定義です。必要な部分のみ抜き出しています。パラメータは optional_name という任意の文字列フィールドがあるだけです。OpenAPIでは何も指定しないと任意になり、必須にしたい場合は required キーで別途定義します。

paths:
  /example:
    post:
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                optional_name:
                  type: string

openapi-typescriptで型情報を生成

CLIコマンドでYAMLやJSONファイルから型情報を生成できます。

npx openapi-typescript sample.openapi.yaml -o sample.openapi.d.ts

以下は生成したTypeScriptの型情報です。必要な部分のみ抜き出しています。

export interface paths {
    "/example": {
        post: {
            requestBody?: {
                content: {
                    "application/json": {
                        optional_name?: string;
                    };
                };
            };
        };
    };
};

optional_name?: string; という型定義になっていることが確認できます。OpenAPIの定義通りですね。

Zodでパラメータをパースする

つづいで上記のAPIにPOSTするリクエストボディをZodを使ってパースします。

const schema = z.object({
  optional_name: z.string().optional()
});

type Payload = z.infer<typeof schema>;

async function post(payload: Payload) {
  const parsedPayload = schema.parse(payload);
  const { data, error } = await client.POST("/example", {
    body: parsedPayload,
  });
}

Zodでは z.string().optional() とすることで文字列の任意項目を定義できます。

しかし、パースした parsedPayload の型情報を見ると optional_name?: string | undefined; になっています。stringundefined のUnionになっているのがOpenAPIの定義と若干異なります。

// openapi-typescriptで生成した型情報
optional_name?: string;

// Zodで生成した型情報
optional_name?: string | undefined;

この異なる型情報のまま、openapi-fetchライブラリでPOSTするコードの部分を確認するとリクエストボディを渡す箇所で型エラーになりました。

async function post(payload: Payload) {
  const parsedPayload = schema.parse(payload);
  const { data, error } = await client.POST("/example", {
    body: parsedPayload, // 型エラーになる
  });
}

エラー内容は以下のとおりです。

TS2375: Type { optional_name?: string | undefined; } is
  not assignable to type { optional_name?: string; }
  with 'exactOptionalPropertyTypes: true'.

Consider adding undefined to the types of the target's properties.
Types of property optional_name are incompatible.
Type string | undefined is not assignable to type string
Type undefined is not assignable to type string

このエラーを簡単に説明すると「 string のみ受け付ける定義なのに undefined の可能性がある型が渡ってきてるからエラーだよ」ということになります。これは冒頭ふれたように tsconfig.jsonexactOptionalPropertyTypes を有効にしていると発生します。

どうして型エラーになるのか

この型エラーは exactOptionalPropertyTypes を無効にすると、以下のように型の違いがあってもエラーにはなりません。

// openapi-typescriptで生成した型情報
optional_name?: string;

// Zodで生成した型情報
optional_name?: string | undefined;

TypeScriptでは以下の2つは実質同じものとしてみなされ、代入することができます。

interface TypeA {
  optional_name?: string;
}

interface TypeB {
  optional_name?: string | undefined;
}

const typeB: TypeB = { optional_name: undefined };

// 値がundefeindでもTypeAとして代入可能
const typeA: TypeA = typeB;

キーが定義されていないのと、値が undefined では異なりますが、実用上はどちらもfalsyとして扱われるので、あまり意識しないかもしれません。

exactOptionalPropertyTypes を有効にすると ? のオプションを厳密に区別するようになるため const typeA: TypeA = typeB; は型エラーになります。

型が異なる対処方法

openapi-typescript で生成したOpenAPIの型定義のほうが厳密になっているため正しく感じます。

Zodで対応できるか?

そこでZodで optional_name?: string; という型を定義できないか調べてみましたが現時点ではサポートはされていません。要望Issueもあり、作者の@colinhacksも時期メジャーバージョンのv4で取り組むという発言もありました。期待。

Zodではなく自前の型ユーティリティを作って実現することもできそうでしたが、型パズルになりそうだったので今回は避けました。

openapi-typescript の型情報をカスタマイズする

そこで openapi-typescript の型情報をカスタマイズする方法で対処することにします。厳しい型定義を緩めにするということになるので型安全性が損なわれるのではないかという懸念があるかもしれませんが、JSONでリクエスト送受信するAPIであれば実用上はほぼ懸念はないと思います。

なぜならJSONでは undefined はサポートされておらず、値が undefined のキーは消えるからです。つまり実行上は optional_name?: string | undefined;optional_name?: string; になります。

const payload = { optional_name: undefined };
JSON.stringify(payload);
//=> '{}' optional_nameキー自体が消える

openapi-typescript は型情報をカスタマイズする transform という機能を提供しています。その機能を使うとOpenAPIの定義ファイルから型情報を生成するプロセスにさわることができます。

以下は、任意フィールドになっていう項目の型定義をZodの optional_name?: string | undefined; に合わせるコードです。 npx openapi-typescript コマンドの代わりに node ./generate-schema.mjs を実行することで期待の型情報ファイルが手に入ります。

generate-schema.mjs
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import openapiTS, { astToString } from "openapi-typescript";
import ts from "typescript";

const baseDir = path.dirname(fileURLToPath(import.meta.url));

const localPath = new URL(
  path.resolve(baseDir, "sample.openapi.yaml"),
  import.meta.url,
);

const output = await openapiTS(localPath, {
  transform(schemaObject) {
    if (schemaObject.type === "object") {
      const optionalKeys = Object.keys(schemaObject.properties).filter(
        (key) =>
          !schemaObject.required || !schemaObject?.required?.includes(key),
      );
      for (const key of optionalKeys) {
        if (Array.isArray(schemaObject.properties[key].type)) {
          schemaObject.properties[key].type.push("undefined");
        } else {
          schemaObject.properties[key].type = [
            schemaObject.properties[key].type,
            "undefined",
          ];
        }
      }
    }

    if (
      Array.isArray(schemaObject.type) &&
      schemaObject.type.includes("undefined")
    ) {
      const union = [];
      for (const type of schemaObject.type) {
        switch (type) {
          case "integer":
            union.push(ts.factory.createIdentifier("number"));
            break;
          case "array":
            union.push(
              ts.factory.createIdentifier(`${schemaObject.items.type}[]`),
            );
            break;
          default:
            union.push(ts.factory.createIdentifier(type));
        }
      }
      return ts.factory.createUnionTypeNode(union);
    }
  },
});

await fs.promises.writeFile(
  path.resolve(baseDir, "sample.openapi.d.ts"),
  astToString(output),
);

(場当たり的なコードなので対応漏れなどがあるかも知れません)

こうすることで、OpenAPIの型情報とZodスキーマの型情報が一致し、 exactOptionalPropertyTypes を有効にしても型エラーを回避することができました。

exactOptionalPropertyTypes はさまざまな箇所で型エラーを引き起こす可能性があり対処が面倒ですが、有効にしたほうが嬉しいシーンもあるため(ORMライブラリのPrismaなど)、可能であれば有効にしていきたいですね。

ムーザルちゃんねる

Discussion