#6500·trpc

bug: Invalid serialization of zod branded tuples

Author: davazpCreated Feb 15, 2025Updated Aug 31, 2026
Labels🐛 bug: unconfirmed

Provide environment information

  System:
    OS: macOS 15.3.1
    CPU: (16) arm64 Apple M3 Max
    Memory: 17.81 GB / 48.00 GB
    Shell: 5.9 - /bin/zsh
  Binaries:
    Node: 18.16.0 - ~/.nvm/versions/node/v18.16.0/bin/node
    Yarn: 1.22.21 - ~/.nvm/versions/node/v18.16.0/bin/yarn
    npm: 9.5.1 - ~/.nvm/versions/node/v18.16.0/bin/npm
    pnpm: 8.14.3 - ~/.nvm/versions/node/v18.16.0/bin/pnpm
  Browsers:
    Chrome: 133.0.6943.55
    Safari: 18.3
  npmPackages:
    @trpc/client: 11.0.0-rc.741 => 11.0.0-rc.741+caed91ee7 
    @trpc/server: 11.0.0-rc.741 => 11.0.0-rc.741+caed91ee7 
    typescript: 5.7.3 => 5.7.3 

Describe the bug

The trpc client fails to infer the output type of a zod-serialized tuple.

For a procedure like this

typescript
export const Point = z
  .tuple([z.number(), z.number(), z.number()])
  .brand("Point");
export type Point = z.infer<typeof Point>;

export const appRouter = t.router({
  getPoint: t.procedure.query(async () => {
    return {
      point: [1, 2, 3] as Point,
    };
  }),
});
typescript
const result = await trpc.getPoint.query();  // has type
typescript
{
    point: SerializeTuple<[number, number, number] & BRAND<"Point">>;
}

This type isn't assignable to Point:

Type 'SerializeTuple<[number, number, number] & BRAND<"Point">>' is not assignable to type '[number, number, number] & BRAND<"Point">'.
  Type 'SerializeTuple<[number, number, number] & BRAND<"Point">>' is not assignable to type '[number, number, number]'.
    Types of property 'length' are incompatible.
      Type 'number' is not assignable to type '3'

Workaround

I can define a dummy transformer to make trpc bail out and not try to transform the types. Then I get the right type in the frontend.

Link to reproduction

https://github.com/davazp/trpc-bug-branded-tuples

To reproduce

https://github.com/davazp/trpc-bug-branded-tuples

Additional information

The SerializedTuple definition here

typescript
/** JSON serialize [tuples](https://www.typescriptlang.org/docs/handbook/2/objects.html#tuple-types) */
type SerializeTuple<T extends [unknown, ...unknown[]]> = {
  [K in keyof T]: T[K] extends NonJsonPrimitive ? null : Serialize<T[K]>;
};

The indexing here will treat this as an object

typescript
type T = Serialize<[1,2,3] & {__brand: void}>;
typescript
type T = {
    [x: number]: 1 | 3 | 2;
    0: 1;
    1: 2;
    2: 3;
    length: 1 | 3 | 2;
    toString: null;
    toLocaleString: null;
    pop: null;
    push: null;
    concat: null;
    join: null;
    reverse: null;
    shift: null;
    slice: null;
    sort: null;
    splice: null;
    ... 23 more ...;
    __brand: 1 | ... 1 more ... | 2;
}

Some typewizardy is needed. However, I guess this affects other type of branded types and other situations.

It would be simpler if, instead, trpc would just check that the output type is JSON compatible, giving an error otherwise, but returning the original type unmodified.

But sure, then procedures could not return Date for instance. I think it is fair, define a serializer if you want that? But it's a breaking change.

‍‍ Contributing

  • ‍♂️ Yes, I'd be down to file a PR fixing this bug!