Skip to content

Commit fd239b8

Browse files
committed
fix: Readable/Writable types now recursively unwrap nested markers
When Readable<$Read<U>> was resolved, it returned U directly instead of Readable<U>, causing nested $Read markers inside U to not be unwrapped. Same issue for Writable<$Write<U>>.
1 parent f2cf767 commit fd239b8

5 files changed

Lines changed: 167 additions & 11 deletions

File tree

packages/openapi-fetch/test/read-write-visibility/read-write.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,51 @@
1-
import { describe, expect, test } from "vitest";
1+
import { describe, expect, expectTypeOf, test } from "vitest";
22
import { createObservedClient } from "../helpers.js";
33
import type { paths } from "./schemas/read-write.js";
44

55
describe("readOnly/writeOnly", () => {
6+
describe("deeply nested $Read unwrapping through $Read<Object>", () => {
7+
test("$Read should continue recursion when unwrapping $Read<ObjectWithReadProperties>", async () => {
8+
// This tests the fix for a bug where Readable<$Read<U>> returned U directly
9+
// instead of Readable<U>, causing nested $Read markers to not be unwrapped.
10+
// Example: nested: $Read<NestedObject> where NestedObject contains
11+
// entries: $Read<Entry[]> - the inner $Read was not stripped.
12+
const client = createObservedClient<paths>({}, async () =>
13+
Response.json({
14+
id: 1,
15+
items: [
16+
{
17+
id: 1,
18+
nested: {
19+
entries: [{ code: "A1", label: "Label1" }],
20+
},
21+
},
22+
],
23+
}),
24+
);
25+
26+
const { data } = await client.GET("/resources/{id}", {
27+
params: { path: { id: 1 } },
28+
});
29+
30+
// nested is $Read<NestedObject> - should be unwrapped
31+
// NestedObject.entries is $Read<Entry[]> - should ALSO be unwrapped
32+
// Entry.label is $Read<string> - should ALSO be unwrapped
33+
34+
// This would fail before the fix: "Property '0' does not exist on type '$Read<Entry[]>'"
35+
const entries = data?.items[0]?.nested.entries;
36+
expect(entries?.[0]?.code).toBe("A1");
37+
38+
// Type assertions to ensure proper unwrapping at all levels
39+
type EntriesType = NonNullable<typeof data>["items"][number]["nested"]["entries"];
40+
// Should be Entry[] (array), not $Read<Entry[]>
41+
expectTypeOf<EntriesType>().toMatchTypeOf<{ code: string; label: string }[]>();
42+
43+
type LabelType = NonNullable<typeof data>["items"][number]["nested"]["entries"][number]["label"];
44+
// Should be string, not $Read<string>
45+
expectTypeOf<LabelType>().toEqualTypeOf<string>();
46+
});
47+
});
48+
649
describe("request body (POST)", () => {
750
test("CANNOT include readOnly properties", async () => {
851
const client = createObservedClient<paths>({});

packages/openapi-fetch/test/read-write-visibility/schemas/read-write.d.ts

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,16 @@
33
* Do not make direct changes to the file.
44
*/
55

6-
type $Read<T> = {
6+
export type $Read<T> = {
77
readonly $read: T;
88
};
9-
type $Write<T> = {
9+
export type $Write<T> = {
1010
readonly $write: T;
1111
};
12-
type Readable<T> = T extends $Write<any> ? never : T extends $Read<infer U> ? U : T extends (infer E)[] ? Readable<E>[] : T extends object ? {
12+
export type Readable<T> = T extends $Write<any> ? never : T extends $Read<infer U> ? Readable<U> : T extends (infer E)[] ? Readable<E>[] : T extends object ? {
1313
[K in keyof T as NonNullable<T[K]> extends $Write<any> ? never : K]: Readable<T[K]>;
1414
} : T;
15-
type Writable<T> = T extends $Read<any> ? never : T extends $Write<infer U> ? U : T extends (infer E)[] ? Writable<E>[] : T extends object ? {
15+
export type Writable<T> = T extends $Read<any> ? never : T extends $Write<infer U> ? Writable<U> : T extends (infer E)[] ? Writable<E>[] : T extends object ? {
1616
[K in keyof T as NonNullable<T[K]> extends $Read<any> ? never : K]: Writable<T[K]>;
1717
} & {
1818
[K in keyof T as NonNullable<T[K]> extends $Read<any> ? K : never]?: never;
@@ -76,6 +76,43 @@ export interface paths {
7676
patch?: never;
7777
trace?: never;
7878
};
79+
"/resources/{id}": {
80+
parameters: {
81+
query?: never;
82+
header?: never;
83+
path?: never;
84+
cookie?: never;
85+
};
86+
get: {
87+
parameters: {
88+
query?: never;
89+
header?: never;
90+
path: {
91+
id: number;
92+
};
93+
cookie?: never;
94+
};
95+
requestBody?: never;
96+
responses: {
97+
/** @description OK */
98+
200: {
99+
headers: {
100+
[name: string]: unknown;
101+
};
102+
content: {
103+
"application/json": components["schemas"]["Resource"];
104+
};
105+
};
106+
};
107+
};
108+
put?: never;
109+
post?: never;
110+
delete?: never;
111+
options?: never;
112+
head?: never;
113+
patch?: never;
114+
trace?: never;
115+
};
79116
}
80117
export type webhooks = Record<string, never>;
81118
export interface components {
@@ -85,6 +122,21 @@ export interface components {
85122
name: string;
86123
password?: $Write<string>;
87124
};
125+
Resource: {
126+
id: number;
127+
items: components["schemas"]["ResourceItem"][];
128+
};
129+
ResourceItem: {
130+
id: number;
131+
nested: $Read<components["schemas"]["NestedObject"]>;
132+
};
133+
NestedObject: {
134+
entries: $Read<components["schemas"]["Entry"][]>;
135+
};
136+
Entry: {
137+
code: string;
138+
label: $Read<string>;
139+
};
88140
};
89141
responses: never;
90142
parameters: never;

packages/openapi-fetch/test/read-write-visibility/schemas/read-write.yaml

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,21 @@ paths:
2626
application/json:
2727
schema:
2828
$ref: "#/components/schemas/User"
29+
/resources/{id}:
30+
get:
31+
parameters:
32+
- name: id
33+
in: path
34+
required: true
35+
schema:
36+
type: integer
37+
responses:
38+
200:
39+
description: OK
40+
content:
41+
application/json:
42+
schema:
43+
$ref: "#/components/schemas/Resource"
2944
components:
3045
schemas:
3146
User:
@@ -41,3 +56,49 @@ components:
4156
password:
4257
type: string
4358
writeOnly: true
59+
# Tests deeply nested $Read wrapping: nested_details is $Read<NestedType>
60+
# and NestedType contains items which are also $Read wrapped
61+
Resource:
62+
type: object
63+
required:
64+
- id
65+
- items
66+
properties:
67+
id:
68+
type: integer
69+
items:
70+
type: array
71+
items:
72+
$ref: "#/components/schemas/ResourceItem"
73+
ResourceItem:
74+
type: object
75+
required:
76+
- id
77+
- nested
78+
properties:
79+
id:
80+
type: integer
81+
nested:
82+
readOnly: true
83+
$ref: "#/components/schemas/NestedObject"
84+
NestedObject:
85+
type: object
86+
required:
87+
- entries
88+
properties:
89+
entries:
90+
type: array
91+
readOnly: true
92+
items:
93+
$ref: "#/components/schemas/Entry"
94+
Entry:
95+
type: object
96+
required:
97+
- code
98+
- label
99+
properties:
100+
code:
101+
type: string
102+
label:
103+
type: string
104+
readOnly: true

packages/openapi-typescript-helpers/src/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -212,14 +212,14 @@ export type $Write<T> = { readonly $write: T };
212212

213213
/**
214214
* Resolve type for reading (responses): strips $Write properties, unwraps $Read
215-
* - $Read<T> → T (readable)
215+
* - $Read<T> → T (readable), continues recursion
216216
* - $Write<T> → never (excluded from response)
217217
* - object → recursively resolve
218218
*/
219219
export type Readable<T> = T extends $Write<any>
220220
? never
221221
: T extends $Read<infer U>
222-
? U
222+
? Readable<U>
223223
: T extends (infer E)[]
224224
? Readable<E>[]
225225
: T extends object
@@ -228,14 +228,14 @@ export type Readable<T> = T extends $Write<any>
228228

229229
/**
230230
* Resolve type for writing (requests): strips $Read properties, unwraps $Write
231-
* - $Write<T> → T (writable)
231+
* - $Write<T> → T (writable), continues recursion
232232
* - $Read<T> → never (excluded from request)
233233
* - object → recursively resolve
234234
*/
235235
export type Writable<T> = T extends $Read<any>
236236
? never
237237
: T extends $Write<infer U>
238-
? U
238+
? Writable<U>
239239
: T extends (infer E)[]
240240
? Writable<E>[]
241241
: T extends object

packages/openapi-typescript/src/transform/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ const transformers: Record<SchemaTransforms, (node: any, options: GlobalContext)
2222
const READ_WRITE_HELPER_TYPES = `
2323
export type $Read<T> = { readonly $read: T };
2424
export type $Write<T> = { readonly $write: T };
25-
export type Readable<T> = T extends $Write<any> ? never : T extends $Read<infer U> ? U : T extends (infer E)[] ? Readable<E>[] : T extends object ? { [K in keyof T as NonNullable<T[K]> extends $Write<any> ? never : K]: Readable<T[K]> } : T;
26-
export type Writable<T> = T extends $Read<any> ? never : T extends $Write<infer U> ? U : T extends (infer E)[] ? Writable<E>[] : T extends object ? { [K in keyof T as NonNullable<T[K]> extends $Read<any> ? never : K]: Writable<T[K]> } & { [K in keyof T as NonNullable<T[K]> extends $Read<any> ? K : never]?: never } : T;
25+
export type Readable<T> = T extends $Write<any> ? never : T extends $Read<infer U> ? Readable<U> : T extends (infer E)[] ? Readable<E>[] : T extends object ? { [K in keyof T as NonNullable<T[K]> extends $Write<any> ? never : K]: Readable<T[K]> } : T;
26+
export type Writable<T> = T extends $Read<any> ? never : T extends $Write<infer U> ? Writable<U> : T extends (infer E)[] ? Writable<E>[] : T extends object ? { [K in keyof T as NonNullable<T[K]> extends $Read<any> ? never : K]: Writable<T[K]> } & { [K in keyof T as NonNullable<T[K]> extends $Read<any> ? K : never]?: never } : T;
2727
`;
2828

2929
export default function transformSchema(schema: OpenAPI3, ctx: GlobalContext) {

0 commit comments

Comments
 (0)