diff --git a/.changeset/silver-null-bodies.md b/.changeset/silver-null-bodies.md new file mode 100644 index 000000000..709253952 --- /dev/null +++ b/.changeset/silver-null-bodies.md @@ -0,0 +1,5 @@ +--- +"openapi-typescript-helpers": patch +--- + +Preserve optional null properties when filtering request body readOnly markers. diff --git a/packages/openapi-fetch/test/types.test.ts b/packages/openapi-fetch/test/types.test.ts index 102099ea5..cf42afc0d 100644 --- a/packages/openapi-fetch/test/types.test.ts +++ b/packages/openapi-fetch/test/types.test.ts @@ -1,7 +1,55 @@ import type { ErrorResponse, GetResponseContent, OkStatus, SuccessResponse } from "openapi-typescript-helpers"; import { assertType, describe, test } from "vitest"; +import createClient from "../src/index.js"; describe("types", () => { + describe("request body", () => { + interface Paths { + "/": { + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": Components["schemas"]["T"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + }; + } + + interface Components { + schemas: { + T: { + x?: null; + }; + }; + } + + test("allows optional null request body properties", () => { + const demo = (body: Components["schemas"]["T"]) => { + const client = createClient(); + return client.POST("/", { body }); + }; + + assertType<(body: Components["schemas"]["T"]) => Promise>(demo); + }); + }); + describe("GetResponseContent", () => { describe("MixedResponses", () => { interface MixedResponses { diff --git a/packages/openapi-typescript-helpers/src/index.ts b/packages/openapi-typescript-helpers/src/index.ts index 391e0a694..d25fbad7f 100644 --- a/packages/openapi-typescript-helpers/src/index.ts +++ b/packages/openapi-typescript-helpers/src/index.ts @@ -210,6 +210,9 @@ export type $Read = { readonly $read: T }; /** Marker type for writeOnly properties (excluded from response bodies) */ export type $Write = { readonly $write: T }; +type HasReadMarker = [NonNullable] extends [never] ? false : NonNullable extends $Read ? true : false; +type HasWriteMarker = [NonNullable] extends [never] ? false : NonNullable extends $Write ? true : false; + /** * Resolve type for reading (responses): strips $Write properties, unwraps $Read * - $Read → T (readable), continues recursion @@ -224,7 +227,7 @@ export type Readable = : T extends (infer E)[] ? Readable[] : T extends object - ? { [K in keyof T as NonNullable extends $Write ? never : K]: Readable } + ? { [K in keyof T as HasWriteMarker extends true ? never : K]: Readable } : T; /** @@ -241,7 +244,7 @@ export type Writable = : T extends (infer E)[] ? Writable[] : T extends object - ? { [K in keyof T as NonNullable extends $Read ? never : K]: Writable } & { - [K in keyof T as NonNullable extends $Read ? K : never]?: never; + ? { [K in keyof T as HasReadMarker extends true ? never : K]: Writable } & { + [K in keyof T as HasReadMarker extends true ? K : never]?: never; } : T;