openapi-typescript version
7.13.0
Node.js version
25.9.0
OS + version
Linux
Description
I have a custom generate script that uses the openapi-typescript Node API with a transform hook to inject branded primitive types into the generated schema — e.g. turning a userId string field into string & { __brand: "UserId" }. The goal is end-to-end type safety so that a UserId can never be accidentally passed where a plain string is expected.
The problem is that the Readable<T> and Writable<T> types emitted into the generated .d.ts (via READ_WRITE_HELPER_TYPES in src/transform/index.ts) both check T extends object before falling through to the T leaf case. Branded primitives like string & { __brand: "UserId" } satisfy extends object because the & { ... } part is object-shaped — so instead of being returned as-is, they get mapped over like a regular object, expanding every string prototype method into the output type. The result is not assignable back to UserId, which breaks everything downstream.
type ReadableUser = Readable<components["schemas"]["User"]>;
declare const user: ReadableUser;
const id: UserId = user.id;
// TS2322: Type '{ readonly [x: number]: string; toString: {}; charAt: {}; ... __brand: "UserId"; }'
// is not assignable to type 'UserId'.
// Type '{ ... }' is not assignable to type 'string'.
Reproduction
Full repro: https://github.com/deej-io/openapi-fetch-branded-type-repro
generate.ts (run with tsx generate.ts)
import openapiTS, { astToString } from "openapi-typescript";
import ts from "typescript";
import { writeFileSync } from "node:fs";
const brandPreamble = `
export type UserId = string & { __brand: "UserId" };
export type UserTag = string & { __brand: "UserTag" };
`;
const ast = await openapiTS(new URL("./openapi.yaml", import.meta.url), {
readWriteMarkers: true,
transform(schemaObject) {
const brand = (schemaObject as Record<string, unknown>)["x-brand"];
if (typeof brand === "string") return ts.factory.createTypeReferenceNode(brand);
},
});
writeFileSync("./schema.d.ts", brandPreamble + astToString(ast));
openapi.yaml
components:
schemas:
User:
type: object
properties:
id:
type: string
readOnly: true
x-brand: UserId
tag:
type: string
x-brand: UserTag
repro.ts — run tsc --noEmit --strict to see the errors
import type { Readable, Writable, components, UserId, UserTag } from "./schema.d.ts";
type User = components["schemas"]["User"];
type ReadableUser = Readable<User>;
declare const user: ReadableUser;
const id: UserId = user.id;
// TS2322: '{ toString: {}; charAt: {}; ... __brand: "UserId" }' not assignable to 'UserId'
type WritableUser = Writable<User>;
declare const writableUser: WritableUser;
const tag: UserTag = writableUser.tag!;
// TS2322: '{ toString: {}; charAt: {}; ... __brand: "UserTag" }' not assignable to 'UserTag'
Expected result
Readable<UserId> should return UserId. Writable<UserTag> should return UserTag. Branded primitives are leaf values — they shouldn't be recursed into.
Required
Extra
openapi-typescript version
7.13.0
Node.js version
25.9.0
OS + version
Linux
Description
I have a custom generate script that uses the openapi-typescript Node API with a transform hook to inject branded primitive types into the generated schema — e.g. turning a
userIdstring field intostring & { __brand: "UserId" }. The goal is end-to-end type safety so that aUserIdcan never be accidentally passed where a plainstringis expected.The problem is that the
Readable<T>andWritable<T>types emitted into the generated.d.ts(viaREAD_WRITE_HELPER_TYPESinsrc/transform/index.ts) both checkT extends objectbefore falling through to theTleaf case. Branded primitives likestring & { __brand: "UserId" }satisfyextends objectbecause the& { ... }part is object-shaped — so instead of being returned as-is, they get mapped over like a regular object, expanding every string prototype method into the output type. The result is not assignable back toUserId, which breaks everything downstream.Reproduction
Full repro: https://github.com/deej-io/openapi-fetch-branded-type-repro
generate.ts (run with
tsx generate.ts)openapi.yaml
repro.ts — run
tsc --noEmit --strictto see the errorsExpected result
Readable<UserId>should returnUserId.Writable<UserTag>should returnUserTag. Branded primitives are leaf values — they shouldn't be recursed into.Required
npx @redocly/cli@latest lint)Extra