Skip to content

Commit a690e52

Browse files
authored
feat(openapi-typescript): add readOnly/writeOnly support via markers (#2549)
* feat(openapi-typescript): add readOnly/writeOnly support via markers Add opt-in `--read-write-markers` flag that generates `$Read<T>` and `$Write<T>` marker types for readOnly/writeOnly OpenAPI properties. - Wrap readOnly properties with `$Read<T>` - Wrap writeOnly properties with `$Write<T>` - Generate inline helper types (`Readable`, `Writable`) in output - Update openapi-fetch to use `Writable<T>` for request bodies and `Readable<T>` for responses - Add `$Read`, `$Write`, `Readable`, `Writable` types to openapi-typescript-helpers This enables proper type-checking: - readOnly properties are excluded from request bodies - writeOnly properties are excluded from response types Closes #604 * fix: export read-write helper types in generated output * 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>>. * fix: biome formatting in helpers
1 parent 79a443b commit a690e52

File tree

14 files changed

+667
-14
lines changed

14 files changed

+667
-14
lines changed

.changeset/happy-pans-run.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"openapi-typescript": minor
3+
"openapi-typescript-helpers": minor
4+
"openapi-fetch": minor
5+
---
6+
7+
Add readOnly/writeOnly support via `--read-write-markers` flag. When enabled, readOnly properties are wrapped with `$Read<T>` and writeOnly properties with `$Write<T>`. openapi-fetch uses `Readable<T>` and `Writable<T>` helpers to exclude these properties from responses and request bodies respectively.

docs/cli.md

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ The following flags are supported in the CLI:
125125
| `--root-types-keep-casing` | | `false` | Do not convert root type names to pascal case |
126126
| `--make-paths-enum` | | `false` | Generate ApiPaths enum for all paths |
127127
| `--generate-path-params` | | `false` | Generate path parameters for all paths where they are undefined by schema |
128+
| `--read-write-markers` | | `false` | Generate `$Read<T>`/`$Write<T>` markers for readOnly/writeOnly properties |
128129

129130
### pathParamsAsTypes
130131

@@ -234,5 +235,80 @@ export enum ApiPaths {
234235
### generatePathParams
235236

236237
This option is useful for generating path params optimistically when the schema has flaky path parameter definitions.
237-
Checks the path for opening and closing brackets and extracts them as path parameters.
238+
Checks the path for opening and closing brackets and extracts them as path parameters.
238239
Does not override already defined by schema path parameters.
240+
241+
### readWriteMarkers
242+
243+
This option enables proper handling of OpenAPI's `readOnly` and `writeOnly` property modifiers. When enabled, properties are wrapped with marker types that allow [openapi-fetch](/openapi-fetch/) to enforce visibility rules at compile time.
244+
245+
For example, given the following schema:
246+
247+
::: code-group
248+
249+
```yaml [my-openapi-3-schema.yaml]
250+
components:
251+
schemas:
252+
User:
253+
type: object
254+
properties:
255+
id:
256+
type: integer
257+
readOnly: true
258+
name:
259+
type: string
260+
password:
261+
type: string
262+
writeOnly: true
263+
```
264+
265+
:::
266+
267+
Enabling `--read-write-markers` would generate:
268+
269+
::: code-group
270+
271+
```ts [my-openapi-3-schema.d.ts]
272+
// Helper types generated inline when readWriteMarkers is enabled
273+
type $Read<T> = { readonly $read: T };
274+
type $Write<T> = { readonly $write: T };
275+
type Readable<T> = /* ... strips $Write properties, unwraps $Read */;
276+
type Writable<T> = /* ... strips $Read properties, unwraps $Write */;
277+
278+
export interface components {
279+
schemas: {
280+
User: {
281+
id?: $Read<number>;
282+
name?: string;
283+
password?: $Write<string>;
284+
};
285+
};
286+
}
287+
```
288+
289+
:::
290+
291+
When used with [openapi-fetch](/openapi-fetch/), the `Readable<T>` and `Writable<T>` helper types automatically:
292+
293+
- **Exclude `readOnly` properties from request bodies** - You can't accidentally send `id` in a POST/PUT request
294+
- **Exclude `writeOnly` properties from responses** - You can't access `password` on response data
295+
296+
::: code-group
297+
298+
```ts [src/my-project.ts]
299+
import createClient from "openapi-fetch";
300+
import type { paths } from "./my-openapi-3-schema";
301+
302+
const client = createClient<paths>({ baseUrl: "https://api.example.com" });
303+
304+
// ✅ TypeScript error: 'id' is readOnly and not allowed in request body
305+
await client.POST("/users", {
306+
body: { id: 123, name: "Alice", password: "secret" },
307+
});
308+
309+
// ✅ TypeScript error: 'password' is writeOnly and not available in response
310+
const { data } = await client.GET("/users/{id}", { params: { path: { id: 1 } } });
311+
console.log(data?.password); // Error!
312+
```
313+
314+
:::

packages/openapi-fetch/src/index.d.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ import type {
66
MediaType,
77
OperationRequestBodyContent,
88
PathsWithMethod,
9+
Readable,
910
RequiredKeysOf,
1011
ResponseObjectMap,
1112
SuccessResponse,
13+
Writable,
1214
} from "openapi-typescript-helpers";
1315

1416
/** Options for each client instance */
@@ -96,24 +98,26 @@ export type ParamsOption<T> = T extends {
9698
: { params: T["parameters"] }
9799
: DefaultParamsOption;
98100

101+
// Writable<T> strips $Read markers (readOnly properties excluded from request body)
99102
export type RequestBodyOption<T> =
100-
OperationRequestBodyContent<T> extends never
103+
Writable<OperationRequestBodyContent<T>> extends never
101104
? { body?: never }
102105
: IsOperationRequestBodyOptional<T> extends true
103-
? { body?: OperationRequestBodyContent<T> }
104-
: { body: OperationRequestBodyContent<T> };
106+
? { body?: Writable<OperationRequestBodyContent<T>> }
107+
: { body: Writable<OperationRequestBodyContent<T>> };
105108

106109
export type FetchOptions<T> = RequestOptions<T> & Omit<RequestInit, "body" | "headers">;
107110

111+
// Readable<T> strips $Write markers (writeOnly properties excluded from response)
108112
export type FetchResponse<T extends Record<string | number, any>, Options, Media extends MediaType> =
109113
| {
110-
data: ParseAsResponse<SuccessResponse<ResponseObjectMap<T>, Media>, Options>;
114+
data: ParseAsResponse<Readable<SuccessResponse<ResponseObjectMap<T>, Media>>, Options>;
111115
error?: never;
112116
response: Response;
113117
}
114118
| {
115119
data?: never;
116-
error: ErrorResponse<ResponseObjectMap<T>, Media>;
120+
error: Readable<ErrorResponse<ResponseObjectMap<T>, Media>>;
117121
response: Response;
118122
};
119123

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { describe, expect, expectTypeOf, test } from "vitest";
2+
import { createObservedClient } from "../helpers.js";
3+
import type { paths } from "./schemas/read-write.js";
4+
5+
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+
49+
describe("request body (POST)", () => {
50+
test("CANNOT include readOnly properties", async () => {
51+
const client = createObservedClient<paths>({});
52+
53+
await client.POST("/users", {
54+
body: {
55+
// @ts-expect-error - id is readOnly, should NOT be allowed in request
56+
id: 123,
57+
name: "Alice",
58+
password: "secret",
59+
},
60+
});
61+
});
62+
63+
test("CAN include writeOnly properties", async () => {
64+
const client = createObservedClient<paths>({});
65+
66+
// No error - password (writeOnly) is allowed in request
67+
await client.POST("/users", {
68+
body: {
69+
name: "Alice",
70+
password: "secret",
71+
},
72+
});
73+
});
74+
75+
test("CAN include normal properties", async () => {
76+
const client = createObservedClient<paths>({});
77+
78+
// No error - name (normal) is allowed everywhere
79+
await client.POST("/users", {
80+
body: { name: "Alice" },
81+
});
82+
});
83+
});
84+
85+
describe("response body (GET/POST response)", () => {
86+
test("CAN access readOnly properties", async () => {
87+
const client = createObservedClient<paths>({}, async () => Response.json({ id: 1, name: "Alice" }));
88+
89+
const { data } = await client.GET("/users");
90+
// No error - id (readOnly) is available in response
91+
const id: number | undefined = data?.id;
92+
expect(id).toBe(1);
93+
});
94+
95+
test("CANNOT access writeOnly properties", async () => {
96+
const client = createObservedClient<paths>({}, async () => Response.json({ id: 1, name: "Alice" }));
97+
98+
const { data } = await client.GET("/users");
99+
// @ts-expect-error - password is writeOnly, should NOT be in response
100+
const password = data?.password;
101+
expect(password).toBeUndefined();
102+
});
103+
104+
test("CAN access normal properties", async () => {
105+
const client = createObservedClient<paths>({}, async () => Response.json({ id: 1, name: "Alice" }));
106+
107+
const { data } = await client.GET("/users");
108+
// No error - name (normal) is available everywhere
109+
const name: string | undefined = data?.name;
110+
expect(name).toBe("Alice");
111+
});
112+
});
113+
});

0 commit comments

Comments
 (0)