openapi-typescriptとZodで任意フィールドの型エラーの対処
OpenAPI、Swaggerから名前が変わってだいぶ良い印象になったなと謎の感想を持っている@zaruです、こんにちは。TypeScriptでOpenAPIを扱うなら openapi-typescript / openapi-fetchライブラリが圧倒的に良いと思いずっと使っています。また、バリデーションはいくつかありますがZodを使い続けています。
今回は「OpenAPIの任意フィールドが、openapi-typescriptで生成したOpenAPIの型情報と、Zodスキーマの型情報が不一致で型エラーになる」症状の対処法を紹介します。前提として tsconfig.json
で exactOptionalPropertyTypes: 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;
になっています。string
と undefined
の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.json
の exactOptionalPropertyTypes
を有効にしていると発生します。
どうして型エラーになるのか
この型エラーは 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
を実行することで期待の型情報ファイルが手に入ります。
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