Skip to content

Commit d06e072

Browse files
committed
fix(openapi-typescript): guard Readable/Writable against branded primitive types
`string & { __brand: "UserId" }` satisfies `extends object`, so Readable/Writable was mapping over all string prototype methods instead of returning the type as-is. Adds a primitive guard before `extends object` in both types. Fix applied to both READ_WRITE_HELPER_TYPES in src/transform/index.ts and openapi-typescript-helpers.
1 parent 93de2a2 commit d06e072

File tree

4 files changed

+50
-13
lines changed

4 files changed

+50
-13
lines changed

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

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

55
describe("readOnly/writeOnly", () => {
66
describe("deeply nested $Read unwrapping through $Read<Object>", () => {
@@ -110,4 +110,37 @@ describe("readOnly/writeOnly", () => {
110110
expect(name).toBe("Alice");
111111
});
112112
});
113+
114+
describe("branded primitive types", () => {
115+
test("Readable preserves branded string in object property", () => {
116+
type BrandedString = string & { __brand: "BrandedString" };
117+
type Schema = { id: BrandedString; name: string };
118+
// Without fix: Readable maps branded primitives through the object branch,
119+
// expanding ALL string prototype methods and producing a type that is NOT
120+
// assignable back to BrandedString. The assignment below would be a type error.
121+
const result = {} as Readable<Schema>;
122+
const id: BrandedString = result.id;
123+
});
124+
125+
test("Writable preserves branded string in object property", () => {
126+
type BrandedString = string & { __brand: "BrandedString" };
127+
type Schema = { id: BrandedString; name: string };
128+
const result = {} as Writable<Schema>;
129+
const id: BrandedString = result.id;
130+
});
131+
132+
test("Readable preserves branded number in object property", () => {
133+
type UserId = number & { __brand: "UserId" };
134+
type Schema = { id: UserId; name: string };
135+
const result = {} as Readable<Schema>;
136+
const id: UserId = result.id;
137+
});
138+
139+
test("Writable preserves branded number in object property", () => {
140+
type UserId = number & { __brand: "UserId" };
141+
type Schema = { id: UserId; name: string };
142+
const result = {} as Writable<Schema>;
143+
const id: UserId = result.id;
144+
});
145+
});
113146
});

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ export type $Read<T> = {
99
export type $Write<T> = {
1010
readonly $write: T;
1111
};
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 ? {
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 string | number | boolean | bigint | symbol ? T : T extends object ? {
1313
[K in keyof T as NonNullable<T[K]> extends $Write<any> ? never : K]: Readable<T[K]>;
1414
} : T;
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 ? {
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 string | number | boolean | bigint | symbol ? T : 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;

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

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -223,9 +223,11 @@ export type Readable<T> =
223223
? Readable<U>
224224
: T extends (infer E)[]
225225
? Readable<E>[]
226-
: T extends object
227-
? { [K in keyof T as NonNullable<T[K]> extends $Write<any> ? never : K]: Readable<T[K]> }
228-
: T;
226+
: T extends string | number | boolean | bigint | symbol
227+
? T
228+
: T extends object
229+
? { [K in keyof T as NonNullable<T[K]> extends $Write<any> ? never : K]: Readable<T[K]> }
230+
: T;
229231

230232
/**
231233
* Resolve type for writing (requests): strips $Read properties, unwraps $Write
@@ -240,8 +242,10 @@ export type Writable<T> =
240242
? Writable<U>
241243
: T extends (infer E)[]
242244
? Writable<E>[]
243-
: T extends object
244-
? { [K in keyof T as NonNullable<T[K]> extends $Read<any> ? never : K]: Writable<T[K]> } & {
245-
[K in keyof T as NonNullable<T[K]> extends $Read<any> ? K : never]?: never;
246-
}
247-
: T;
245+
: T extends string | number | boolean | bigint | symbol
246+
? T
247+
: T extends object
248+
? { [K in keyof T as NonNullable<T[K]> extends $Read<any> ? never : K]: Writable<T[K]> } & {
249+
[K in keyof T as NonNullable<T[K]> extends $Read<any> ? K : never]?: never;
250+
}
251+
: T;

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> ? 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;
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 string | number | boolean | bigint | symbol ? T : 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 string | number | boolean | bigint | symbol ? T : 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)