Skip to content

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

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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