diff --git a/.changeset/fix-readable-markers.md b/.changeset/fix-readable-markers.md new file mode 100644 index 000000000..842ad5342 --- /dev/null +++ b/.changeset/fix-readable-markers.md @@ -0,0 +1,25 @@ +--- +"openapi-typescript-helpers": patch +"openapi-fetch": minor +"openapi-typescript": patch +--- + +Fix `Readable` and `Writable` types to correctly handle readonly arrays, and make marker resolution opt-in via `Markers` type parameter (default: `false`). This fixes issues where `Readable` would destructure the array into an object type (#2615), and where applying `Readable`/`Writable` unconditionally broke discriminated union narrowing (#2620). + +### Breaking change for `--read-write-markers` users + +`Readable`/`Writable` are no longer applied unconditionally to all responses and request bodies. If you use `--read-write-markers` with `openapi-fetch`, you now need to pass `true` as the third type parameter to opt in to marker resolution: + +```ts +// Before (0.17.x): markers resolved automatically +const client = createClient(); + +// After: opt in explicitly +import type { MediaType } from "openapi-typescript-helpers"; +// ↓ MediaType is required as a positional placeholder +const client = createClient(); +``` + +The second type parameter (`MediaType`) is the media type filter — it defaults to `MediaType` which accepts all content types. You must specify it explicitly to reach the third `Markers` parameter, since TypeScript does not support skipping positional type arguments. + +Without `Markers = true`, `$Read`/`$Write` markers will be treated as raw wrapper types and not resolved. This is the intended default for users who do not use `--read-write-markers`. diff --git a/packages/openapi-fetch/src/index.d.ts b/packages/openapi-fetch/src/index.d.ts index 5bca81075..7a0f43eac 100644 --- a/packages/openapi-fetch/src/index.d.ts +++ b/packages/openapi-fetch/src/index.d.ts @@ -3,14 +3,14 @@ import type { FilterKeys, HttpMethod, IsOperationRequestBodyOptional, + MaybeReadable, + MaybeWritable, MediaType, OperationRequestBodyContent, PathsWithMethod, - Readable, RequiredKeysOf, ResponseObjectMap, SuccessResponse, - Writable, } from "openapi-typescript-helpers"; /** Options for each client instance */ @@ -98,31 +98,37 @@ export type ParamsOption = T extends { : { params: T["parameters"] } : DefaultParamsOption; -// Writable strips $Read markers (readOnly properties excluded from request body) -export type RequestBodyOption = - Writable> extends never +// MaybeWritable strips $Read markers when Markers is true (readOnly properties excluded from request body) +export type RequestBodyOption = + MaybeWritable, Markers> extends never ? { body?: never } : IsOperationRequestBodyOptional extends true - ? { body?: Writable> } - : { body: Writable> }; + ? { body?: MaybeWritable, Markers> } + : { body: MaybeWritable, Markers> }; -export type FetchOptions = RequestOptions & Omit; +export type FetchOptions = RequestOptions & + Omit; -// Readable strips $Write markers (writeOnly properties excluded from response) -export type FetchResponse, Options, Media extends MediaType> = +// MaybeReadable strips $Write markers when Markers is true (writeOnly properties excluded from response) +export type FetchResponse< + T extends Record, + Options, + Media extends MediaType, + Markers extends boolean = false, +> = | { - data: ParseAsResponse, Media>>, Options>; + data: ParseAsResponse, Media>, Markers>, Options>; error?: never; response: Response; } | { data?: never; - error: Readable, Media>>; + error: MaybeReadable, Media>, Markers>; response: Response; }; -export type RequestOptions = ParamsOption & - RequestBodyOption & { +export type RequestOptions = ParamsOption & + RequestBodyOption & { baseUrl?: string; querySerializer?: QuerySerializer | QuerySerializerOptions; bodySerializer?: BodySerializer; @@ -190,10 +196,10 @@ export type Middleware = }; /** This type helper makes the 2nd function param required if params/requestBody are required; otherwise, optional */ -export type MaybeOptionalInit = - RequiredKeysOf>> extends never - ? FetchOptions> | undefined - : FetchOptions>; +export type MaybeOptionalInit = + RequiredKeysOf, Markers>> extends never + ? FetchOptions, Markers> | undefined + : FetchOptions, Markers>; // The final init param to accept. // - Determines if the param is optional or not. @@ -206,79 +212,102 @@ export type ClientMethod< Paths extends Record>, Method extends HttpMethod, Media extends MediaType, -> = , Init extends MaybeOptionalInit>( + Markers extends boolean = false, +> = , Init extends MaybeOptionalInit>( url: Path, ...init: InitParam -) => Promise>; +) => Promise>; -export type ClientRequestMethod>, Media extends MediaType> = < +export type ClientRequestMethod< + Paths extends Record>, + Media extends MediaType, + Markers extends boolean = false, +> = < Method extends HttpMethod, Path extends PathsWithMethod, - Init extends MaybeOptionalInit, + Init extends MaybeOptionalInit, >( method: Method, url: Path, ...init: InitParam -) => Promise>; +) => Promise>; -export type ClientForPath, Media extends MediaType> = { - [Method in keyof PathInfo as Uppercase]: >( +export type ClientForPath< + PathInfo extends Record, + Media extends MediaType, + Markers extends boolean = false, +> = { + [Method in keyof PathInfo as Uppercase]: >( ...init: InitParam - ) => Promise>; + ) => Promise>; }; -export interface Client { - request: ClientRequestMethod; +export interface Client { + request: ClientRequestMethod; /** Call a GET endpoint */ - GET: ClientMethod; + GET: ClientMethod; /** Call a PUT endpoint */ - PUT: ClientMethod; + PUT: ClientMethod; /** Call a POST endpoint */ - POST: ClientMethod; + POST: ClientMethod; /** Call a DELETE endpoint */ - DELETE: ClientMethod; + DELETE: ClientMethod; /** Call a OPTIONS endpoint */ - OPTIONS: ClientMethod; + OPTIONS: ClientMethod; /** Call a HEAD endpoint */ - HEAD: ClientMethod; + HEAD: ClientMethod; /** Call a PATCH endpoint */ - PATCH: ClientMethod; + PATCH: ClientMethod; /** Call a TRACE endpoint */ - TRACE: ClientMethod; + TRACE: ClientMethod; /** Register middleware */ use(...middleware: Middleware[]): void; /** Unregister middleware */ eject(...middleware: Middleware[]): void; } -export type ClientPathsWithMethod, Method extends HttpMethod> = - CreatedClient extends Client ? PathsWithMethod : never; +export type ClientPathsWithMethod, Method extends HttpMethod> = + CreatedClient extends Client ? PathsWithMethod : never; export type MethodResponse< - CreatedClient extends Client, + CreatedClient extends Client, Method extends HttpMethod, Path extends ClientPathsWithMethod, Options = {}, > = - CreatedClient extends Client - ? NonNullable["data"]> + CreatedClient extends Client< + infer Paths extends { [key: string]: any }, + infer Media extends MediaType, + infer Markers extends boolean + > + ? NonNullable["data"]> : never; -export default function createClient( - clientOptions?: ClientOptions, -): Client; - -export type PathBasedClient, Media extends MediaType = MediaType> = { - [Path in keyof Paths]: ClientForPath; +export default function createClient< + Paths extends {}, + Media extends MediaType = MediaType, + Markers extends boolean = false, +>(clientOptions?: ClientOptions): Client; + +export type PathBasedClient< + Paths extends Record, + Media extends MediaType = MediaType, + Markers extends boolean = false, +> = { + [Path in keyof Paths]: ClientForPath; }; -export declare function wrapAsPathBasedClient( - client: Client, -): PathBasedClient; - -export declare function createPathBasedClient( - clientOptions?: ClientOptions, -): PathBasedClient; +export declare function wrapAsPathBasedClient< + Paths extends {}, + Media extends MediaType = MediaType, + Markers extends boolean = false, +>(client: Client): PathBasedClient; + +export declare function createPathBasedClient< + Paths extends {}, + Media extends MediaType = MediaType, + Markers extends boolean = false, +>(clientOptions?: ClientOptions): PathBasedClient; /** Serialize primitive params to string */ export declare function serializePrimitiveParam( diff --git a/packages/openapi-fetch/test/helpers.ts b/packages/openapi-fetch/test/helpers.ts index bece1b17a..e9bbd8b0b 100644 --- a/packages/openapi-fetch/test/helpers.ts +++ b/packages/openapi-fetch/test/helpers.ts @@ -1,5 +1,5 @@ import type { MediaType } from "openapi-typescript-helpers"; -import createClient from "../src/index.js"; +import createClient, { type ClientOptions } from "../src/index.js"; /** * Create a client instance where all requests use a custom fetch implementation. @@ -12,11 +12,11 @@ import createClient from "../src/index.js"; * If you have too much going on in one handler, just make another instance. These are cheap. */ // Note: this isn’t called “createMockedClient” because ✨ nothing is mocked 🌈! It’s only calling the handler you pass in. -export function createObservedClient( - options?: Parameters>[0], +export function createObservedClient( + options?: ClientOptions, onRequest: (input: Request) => Promise = async () => Response.json({ status: 200, message: "OK" }), ) { - return createClient({ + return createClient({ ...options, baseUrl: options?.baseUrl || "https://fake-api.example", // Node.js requires a domain for Request(). This restriction doesn’t exist in browsers, but we are using `e2e.test.ts` for that.. fetch: (input) => onRequest(input), diff --git a/packages/openapi-fetch/test/read-write-visibility/read-write.test.ts b/packages/openapi-fetch/test/read-write-visibility/read-write.test.ts index b699887af..3beb64d76 100644 --- a/packages/openapi-fetch/test/read-write-visibility/read-write.test.ts +++ b/packages/openapi-fetch/test/read-write-visibility/read-write.test.ts @@ -1,6 +1,9 @@ +import type { $Read, Readable } from "openapi-typescript-helpers"; import { describe, expect, expectTypeOf, test } from "vitest"; +import type { Client, MethodResponse } from "../../src/index.js"; import { createObservedClient } from "../helpers.js"; import type { paths } from "./schemas/read-write.js"; +import type { paths as noMarkerPaths } from "./schemas/read-write-no-markers.js"; describe("readOnly/writeOnly", () => { describe("deeply nested $Read unwrapping through $Read", () => { @@ -9,7 +12,7 @@ describe("readOnly/writeOnly", () => { // instead of Readable, causing nested $Read markers to not be unwrapped. // Example: nested: $Read where NestedObject contains // entries: $Read - the inner $Read was not stripped. - const client = createObservedClient({}, async () => + const client = createObservedClient({}, async () => Response.json({ id: 1, items: [ @@ -48,7 +51,7 @@ describe("readOnly/writeOnly", () => { describe("request body (POST)", () => { test("CANNOT include readOnly properties", async () => { - const client = createObservedClient({}); + const client = createObservedClient({}); await client.POST("/users", { body: { @@ -61,7 +64,7 @@ describe("readOnly/writeOnly", () => { }); test("CAN include writeOnly properties", async () => { - const client = createObservedClient({}); + const client = createObservedClient({}); // No error - password (writeOnly) is allowed in request await client.POST("/users", { @@ -73,7 +76,7 @@ describe("readOnly/writeOnly", () => { }); test("CAN include normal properties", async () => { - const client = createObservedClient({}); + const client = createObservedClient({}); // No error - name (normal) is allowed everywhere await client.POST("/users", { @@ -84,7 +87,9 @@ describe("readOnly/writeOnly", () => { describe("response body (GET/POST response)", () => { test("CAN access readOnly properties", async () => { - const client = createObservedClient({}, async () => Response.json({ id: 1, name: "Alice" })); + const client = createObservedClient({}, async () => + Response.json({ id: 1, name: "Alice" }), + ); const { data } = await client.GET("/users"); // No error - id (readOnly) is available in response @@ -93,7 +98,9 @@ describe("readOnly/writeOnly", () => { }); test("CANNOT access writeOnly properties", async () => { - const client = createObservedClient({}, async () => Response.json({ id: 1, name: "Alice" })); + const client = createObservedClient({}, async () => + Response.json({ id: 1, name: "Alice" }), + ); const { data } = await client.GET("/users"); // @ts-expect-error - password is writeOnly, should NOT be in response @@ -102,7 +109,9 @@ describe("readOnly/writeOnly", () => { }); test("CAN access normal properties", async () => { - const client = createObservedClient({}, async () => Response.json({ id: 1, name: "Alice" })); + const client = createObservedClient({}, async () => + Response.json({ id: 1, name: "Alice" }), + ); const { data } = await client.GET("/users"); // No error - name (normal) is available everywhere @@ -110,4 +119,139 @@ describe("readOnly/writeOnly", () => { expect(name).toBe("Alice"); }); }); + + describe("markers opt-in (default: off)", () => { + test("without Markers, $Read/$Write markers are NOT resolved", async () => { + // Default createClient — Markers = false + const client = createObservedClient({}, async () => + Response.json({ id: 1, name: "Alice", password: "secret" }), + ); + + const { data } = await client.GET("/users"); + // Without markers, $Write is NOT stripped — password is accessible + // (it's treated as a raw type, not resolved) + const password = data?.password; + expect(password).toBe("secret"); + }); + }); + + describe("MethodResponse with Markers", () => { + test("MethodResponse infers Markers from Client type", () => { + // Verify that MethodResponse correctly infers Markers from a Client<..., true> + type MarkerClient = Client; + type Resp = MethodResponse; + // With Markers=true, id ($Read) should be unwrapped to number + expectTypeOf().toHaveProperty("id"); + expectTypeOf().toHaveProperty("name"); + }); + + test("MethodResponse works with default (no Markers) client", () => { + type DefaultClient = Client; + type Resp = MethodResponse; + // Without markers, $Read/$Write are raw types — all properties present + expectTypeOf().toHaveProperty("name"); + }); + }); + + describe("#2615: Readable preserves array types", () => { + test("readonly arrays are not destructured into object types", () => { + // Before fix: Readable fell through to `T extends object`, + // producing { readonly [x: number]: string; length: number; ... } instead of an array + expectTypeOf>().toEqualTypeOf(); + }); + + test("mutable arrays remain mutable", () => { + expectTypeOf>().toEqualTypeOf(); + }); + + test("nested readonly arrays are preserved", () => { + expectTypeOf>().toEqualTypeOf(); + }); + }); + + describe("flag OFF: no markers (read-write-no-markers fixture)", () => { + test("readOnly properties use readonly modifier instead of $Read", () => { + // In the no-markers fixture, readOnly fields are plain `readonly` modifiers + type User = noMarkerPaths["/users"]["get"]["responses"]["200"]["content"]["application/json"]; + // id is `readonly id?: number` — accessible without $Read unwrapping + expectTypeOf().toEqualTypeOf(); + }); + + test("writeOnly properties remain as normal types without $Write", () => { + type User = noMarkerPaths["/users"]["get"]["responses"]["200"]["content"]["application/json"]; + // password is `password?: string` — no $Write wrapper + expectTypeOf().toEqualTypeOf(); + }); + + test("default client can access all properties without marker resolution", async () => { + const client = createObservedClient({}, async () => + Response.json({ id: 1, name: "Alice", password: "secret" }), + ); + + const { data } = await client.GET("/users"); + expect(data?.id).toBe(1); + expect(data?.name).toBe("Alice"); + // Without markers, password is a normal property — accessible in response + expect(data?.password).toBe("secret"); + }); + + test("nested readonly properties use readonly modifier", () => { + type Entry = + noMarkerPaths["/resources/{id}"]["get"]["responses"]["200"]["content"]["application/json"]["items"][number]["nested"]["entries"][number]; + // label is `readonly label: string` — accessible as plain string + expectTypeOf().toEqualTypeOf(); + }); + }); + + describe("#2620: discriminated union narrowing", () => { + test("narrowing preserved when markers disabled (default)", async () => { + // The core fix: Readable is no longer applied when Markers=false, + // so the original discriminated union type identity is preserved and + // TypeScript's control-flow narrowing works as expected. + const client = createObservedClient({}, async () => Response.json({ type: "success", payload: "ok" })); + + const { data } = await client.GET("/events"); + if (data) { + if (data.type === "success") { + // Without Readable wrapping, narrowing works: 'payload' exists on SuccessEvent + expectTypeOf(data.payload).toEqualTypeOf(); + expect(data.payload).toBe("ok"); + } + } + }); + + test("narrowing preserved with markers on non-marker union members", async () => { + // When Markers=true but union members have no $Read/$Write markers, + // Readable recurses into the object branch but produces a structurally + // identical type, preserving narrowing for simple discriminated unions. + const client = createObservedClient({}, async () => + Response.json({ type: "error", message: "not found" }), + ); + + const { data } = await client.GET("/events"); + if (data) { + if (data.type === "error") { + expectTypeOf(data.message).toEqualTypeOf(); + expect(data.message).toBe("not found"); + } + } + }); + + test("narrowing preserved when union members contain $Read markers", () => { + // Verify that Readable distributes over the union and unwraps $Read + // while preserving the discriminant for control-flow narrowing. + type MarkedUnion = { type: "success"; data: string; audit: $Read } | { type: "error"; message: string }; + + type Result = Readable; + + // Use control-flow narrowing on the resolved type + const value = {} as Result; + if (value.type === "success") { + expectTypeOf(value.data).toEqualTypeOf(); + expectTypeOf(value.audit).toEqualTypeOf(); + } else { + expectTypeOf(value.message).toEqualTypeOf(); + } + }); + }); }); diff --git a/packages/openapi-fetch/test/read-write-visibility/schemas/read-write-no-markers.d.ts b/packages/openapi-fetch/test/read-write-visibility/schemas/read-write-no-markers.d.ts new file mode 100644 index 000000000..4951dae70 --- /dev/null +++ b/packages/openapi-fetch/test/read-write-visibility/schemas/read-write-no-markers.d.ts @@ -0,0 +1,185 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["User"]; + }; + }; + }; + }; + put?: never; + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["User"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["User"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessEvent"] | components["schemas"]["ErrorEvent"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/resources/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Resource"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + User: { + readonly id?: number; + name: string; + password?: string; + }; + Resource: { + id: number; + items: components["schemas"]["ResourceItem"][]; + }; + ResourceItem: { + id: number; + readonly nested: components["schemas"]["NestedObject"]; + }; + NestedObject: { + readonly entries: components["schemas"]["Entry"][]; + }; + Entry: { + code: string; + readonly label: string; + }; + SuccessEvent: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "success"; + payload: string; + }; + ErrorEvent: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "error"; + message: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export type operations = Record; diff --git a/packages/openapi-fetch/test/read-write-visibility/schemas/read-write.d.ts b/packages/openapi-fetch/test/read-write-visibility/schemas/read-write.d.ts index 2154b1e38..bd4edcbcb 100644 --- a/packages/openapi-fetch/test/read-write-visibility/schemas/read-write.d.ts +++ b/packages/openapi-fetch/test/read-write-visibility/schemas/read-write.d.ts @@ -9,10 +9,10 @@ export type $Read = { export type $Write = { readonly $write: T; }; -export type Readable = T extends $Write ? never : T extends $Read ? Readable : T extends (infer E)[] ? Readable[] : T extends object ? { +export type Readable = T extends $Write ? never : T extends $Read ? Readable : T extends (infer E)[] ? Readable[] : T extends readonly (infer E)[] ? readonly Readable[] : T extends object ? { [K in keyof T as NonNullable extends $Write ? never : K]: Readable; } : T; -export type Writable = T extends $Read ? never : T extends $Write ? Writable : T extends (infer E)[] ? Writable[] : T extends object ? { +export type Writable = T extends $Read ? never : T extends $Write ? Writable : T extends (infer E)[] ? Writable[] : T extends readonly (infer E)[] ? readonly 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; @@ -76,6 +76,41 @@ export interface paths { patch?: never; trace?: never; }; + "/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessEvent"] | components["schemas"]["ErrorEvent"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/resources/{id}": { parameters: { query?: never; @@ -137,6 +172,22 @@ export interface components { code: string; label: $Read; }; + SuccessEvent: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "success"; + payload: string; + }; + ErrorEvent: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "error"; + message: string; + }; }; responses: never; parameters: never; diff --git a/packages/openapi-fetch/test/read-write-visibility/schemas/read-write.yaml b/packages/openapi-fetch/test/read-write-visibility/schemas/read-write.yaml index 77a1a3d63..701171346 100644 --- a/packages/openapi-fetch/test/read-write-visibility/schemas/read-write.yaml +++ b/packages/openapi-fetch/test/read-write-visibility/schemas/read-write.yaml @@ -26,6 +26,22 @@ paths: application/json: schema: $ref: "#/components/schemas/User" + /events: + get: + responses: + 200: + description: OK + content: + application/json: + schema: + oneOf: + - $ref: "#/components/schemas/SuccessEvent" + - $ref: "#/components/schemas/ErrorEvent" + discriminator: + propertyName: type + mapping: + success: "#/components/schemas/SuccessEvent" + error: "#/components/schemas/ErrorEvent" /resources/{id}: get: parameters: @@ -102,3 +118,28 @@ components: label: type: string readOnly: true + # Discriminated union for narrowing regression test (#2620) + SuccessEvent: + type: object + required: + - type + - payload + properties: + type: + type: string + enum: + - success + payload: + type: string + ErrorEvent: + type: object + required: + - type + - message + properties: + type: + type: string + enum: + - error + message: + type: string diff --git a/packages/openapi-fetch/test/redocly.yaml b/packages/openapi-fetch/test/redocly.yaml index 1f211f405..84d46f18f 100644 --- a/packages/openapi-fetch/test/redocly.yaml +++ b/packages/openapi-fetch/test/redocly.yaml @@ -58,3 +58,7 @@ apis: x-openapi-ts: output: ./read-write-visibility/schemas/read-write.d.ts read-write-markers: true + read-write-no-markers: + root: ./read-write-visibility/schemas/read-write.yaml + x-openapi-ts: + output: ./read-write-visibility/schemas/read-write-no-markers.d.ts diff --git a/packages/openapi-typescript-helpers/src/index.ts b/packages/openapi-typescript-helpers/src/index.ts index 391e0a694..535dde644 100644 --- a/packages/openapi-typescript-helpers/src/index.ts +++ b/packages/openapi-typescript-helpers/src/index.ts @@ -214,6 +214,7 @@ export type $Write = { readonly $write: T }; * Resolve type for reading (responses): strips $Write properties, unwraps $Read * - $Read → T (readable), continues recursion * - $Write → never (excluded from response) + * - readonly T[] / T[] → recursively resolve elements * - object → recursively resolve */ export type Readable = @@ -223,14 +224,17 @@ export type Readable = ? Readable : T extends (infer E)[] ? Readable[] - : T extends object - ? { [K in keyof T as NonNullable extends $Write ? never : K]: Readable } - : T; + : T extends readonly (infer E)[] + ? readonly Readable[] + : T extends object + ? { [K in keyof T as NonNullable extends $Write ? never : K]: Readable } + : T; /** * Resolve type for writing (requests): strips $Read properties, unwraps $Write * - $Write → T (writable), continues recursion * - $Read → never (excluded from request) + * - readonly T[] / T[] → recursively resolve elements * - object → recursively resolve */ export type Writable = @@ -240,8 +244,16 @@ export type Writable = ? 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; - } - : T; + : T extends readonly (infer E)[] + ? readonly 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; + } + : T; + +/** Apply Readable only when read/write markers are enabled */ +export type MaybeReadable = Markers extends true ? Readable : T; + +/** Apply Writable only when read/write markers are enabled */ +export type MaybeWritable = Markers extends true ? Writable : T; diff --git a/packages/openapi-typescript/src/transform/index.ts b/packages/openapi-typescript/src/transform/index.ts index ad4af119a..260fc4df0 100644 --- a/packages/openapi-typescript/src/transform/index.ts +++ b/packages/openapi-typescript/src/transform/index.ts @@ -22,8 +22,8 @@ const transformers: Record = { readonly $read: T }; export type $Write = { readonly $write: T }; -export type Readable = T extends $Write ? never : T extends $Read ? Readable : T extends (infer E)[] ? Readable[] : T extends object ? { [K in keyof T as NonNullable extends $Write ? never : K]: Readable } : T; -export type Writable = T extends $Read ? never : T extends $Write ? 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 } : T; +export type Readable = T extends $Write ? never : T extends $Read ? Readable : T extends (infer E)[] ? Readable[] : T extends readonly (infer E)[] ? readonly Readable[] : T extends object ? { [K in keyof T as NonNullable extends $Write ? never : K]: Readable } : T; +export type Writable = T extends $Read ? never : T extends $Write ? Writable : T extends (infer E)[] ? Writable[] : T extends readonly (infer E)[] ? readonly 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 } : T; `; export default function transformSchema(schema: OpenAPI3, ctx: GlobalContext) {