From a863f278d5258249ed65b106b64c397c6be717df Mon Sep 17 00:00:00 2001 From: "Daniel J. Rollins" Date: Mon, 13 Apr 2026 17:31:58 +0100 Subject: [PATCH] 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. --- .../read-write-visibility/read-write.test.ts | 35 ++++++++++++++++++- .../schemas/read-write.d.ts | 4 +-- .../openapi-typescript-helpers/src/index.ts | 20 ++++++----- .../openapi-typescript/src/transform/index.ts | 4 +-- 4 files changed, 50 insertions(+), 13 deletions(-) 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..7a20d7c4a 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,6 @@ import { describe, expect, expectTypeOf, test } from "vitest"; import { createObservedClient } from "../helpers.js"; -import type { paths } from "./schemas/read-write.js"; +import type { paths, Readable, Writable } from "./schemas/read-write.js"; describe("readOnly/writeOnly", () => { describe("deeply nested $Read unwrapping through $Read", () => { @@ -110,4 +110,37 @@ describe("readOnly/writeOnly", () => { expect(name).toBe("Alice"); }); }); + + describe("branded primitive types", () => { + test("Readable preserves branded string in object property", () => { + type BrandedString = string & { __brand: "BrandedString" }; + type Schema = { id: BrandedString; name: string }; + // Without fix: Readable maps branded primitives through the object branch, + // expanding ALL string prototype methods and producing a type that is NOT + // assignable back to BrandedString. The assignment below would be a type error. + const result = {} as Readable; + const _id: BrandedString = result.id; + }); + + test("Writable preserves branded string in object property", () => { + type BrandedString = string & { __brand: "BrandedString" }; + type Schema = { id: BrandedString; name: string }; + const result = {} as Writable; + const _id: BrandedString = result.id; + }); + + test("Readable preserves branded number in object property", () => { + type UserId = number & { __brand: "UserId" }; + type Schema = { id: UserId; name: string }; + const result = {} as Readable; + const _id: UserId = result.id; + }); + + test("Writable preserves branded number in object property", () => { + type UserId = number & { __brand: "UserId" }; + type Schema = { id: UserId; name: string }; + const result = {} as Writable; + const _id: UserId = result.id; + }); + }); }); 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..c4db3a67f 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 string | number | boolean | bigint | symbol ? T : 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 string | number | boolean | bigint | symbol ? T : 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; diff --git a/packages/openapi-typescript-helpers/src/index.ts b/packages/openapi-typescript-helpers/src/index.ts index 391e0a694..a61aff605 100644 --- a/packages/openapi-typescript-helpers/src/index.ts +++ b/packages/openapi-typescript-helpers/src/index.ts @@ -223,9 +223,11 @@ 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 string | number | boolean | bigint | symbol + ? T + : 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 @@ -240,8 +242,10 @@ 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 string | number | boolean | bigint | symbol + ? T + : 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; diff --git a/packages/openapi-typescript/src/transform/index.ts b/packages/openapi-typescript/src/transform/index.ts index ad4af119a..ee2938719 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 string | number | boolean | bigint | symbol ? T : 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 string | number | boolean | bigint | symbol ? T : 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) {