Skip to content

Readable/Writable breaks branded primitive types (string & {...}, number & {...}) #2765

@deej-io

Description

@deej-io

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

  • My OpenAPI schema is valid and passes the Redocly validator (npx @redocly/cli@latest lint)

Extra

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingopenapi-tsRelevant to the openapi-typescript library

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions