Skip to content

Rpc.Serializable rejects ReadonlyMap and ReadonlySet, silently collapsing Durable Object RPC methods to never #7482

Description

@kana001-bit

Summary

A Durable Object RPC method that returns a ReadonlyMap or a ReadonlySet fails the
T extends Rpc.Serializable<T> constraint, even though the value crosses the RPC boundary
fine at runtime. Because the constraint failure makes the caller-side type never, and
never is assignable to everything, tsc reports no error at the definition site or at
any call site.
The method just silently becomes unusable.

ReadonlyArray is already handled; ReadonlyMap and ReadonlySet look like an oversight
rather than a decision.

Where

types/defines/rpc.d.ts:

type Serializable<T> =
  | BaseType
  | Map<
      T extends Map<infer U, unknown> ? Serializable<U> : never,
      T extends Map<unknown, infer U> ? Serializable<U> : never
    >
  | Set<T extends Set<infer U> ? Serializable<U> : never>
  | ReadonlyArray<T extends ReadonlyArray<infer U> ? Serializable<U> : never>  // <-- readonly handled here
  | { [K in keyof T]: K extends number | string ? Serializable<T[K]> : never }
  | Stub<Stubable>
  | Stubable;

ReadonlyMap is not assignable to Map (it lacks set/delete/clear), and it does not
match the mapped-type branch either, so it falls through the whole union. Same for ReadonlySet.

Repro 1 — the constraint

/// requires @cloudflare/workers-types
type IsSerializable<T> = [T] extends [Rpc.Serializable<T>] ? true : false;
declare function assertTrue<T extends true>(): void;

assertTrue<IsSerializable<string[]>>();                      // ok
assertTrue<IsSerializable<readonly string[]>>();             // ok
assertTrue<IsSerializable<Map<string, number>>>();           // ok
assertTrue<IsSerializable<ReadonlyMap<string, number>>>();   // error TS2344: Type 'false' does not satisfy the constraint 'true'.
assertTrue<IsSerializable<Set<string>>>();                   // ok
assertTrue<IsSerializable<ReadonlySet<string>>>();           // error TS2344: Type 'false' does not satisfy the constraint 'true'.

Repro 2 — the failure is silent

interface Room extends Rpc.DurableObjectBranded {
  mutable(): Map<string, number>;
  frozen(): ReadonlyMap<string, number>;
}
declare const stub: DurableObjectStub<Room>;

// Assigned to `0` on purpose, to print what was inferred.
const a: 0 = stub.mutable();  // error TS2322: Type 'Promise<Map<string, number> & Disposable> & Pick<...>' is not assignable to type '0'.
const b: 0 = stub.frozen();   // NO ERROR -- inferred as `never`

There is no diagnostic pointing at frozen. In my case the only thing that caught it was
@typescript-eslint/await-thenable firing on await stub.frozen().

Repro 3 — the runtime accepts the value

ReadonlyMap has no runtime representation, so the value a ReadonlyMap-typed method
returns is the same Map that a Map-typed method returns. Run with @cloudflare/vitest-pool-workers:

// probe.ts
import { DurableObject } from "cloudflare:workers";

export class Probe extends DurableObject {
  frozen(): ReadonlyMap<string, number> {
    return new Map([["a", 1]]);
  }
  mutable(): Map<string, number> {
    return new Map([["a", 1]]);
  }
}
export default { fetch: (): Response => new Response("ok") };
// rpc-readonly.repro.ts
import { env } from "cloudflare:test";
import { expect, it } from "vitest";
import type { Probe } from "./probe";

declare module "cloudflare:test" {
  interface ProvidedEnv {
    PROBE: DurableObjectNamespace<Probe>;
  }
}

const stub = (): DurableObjectStub<Probe> => env.PROBE.get(env.PROBE.idFromName("probe"));

it("Map crosses the RPC boundary", async () => {
  const got = await stub().mutable();
  expect(got).toBeInstanceOf(Map);
  expect(got.get("a")).toBe(1);
});

it("so does the value from a ReadonlyMap-typed method", async () => {
  // `stub().frozen()` is typed `never`; `never` is assignable to `unknown`,
  // which is why this compiles. The call works at runtime.
  const got: unknown = await stub().frozen();
  expect(got).toBeInstanceOf(Map);
  expect(got instanceof Map ? got.get("a") : undefined).toBe(1);
});

Both pass. Changing the value inside Probe.frozen() changes what the second test observes,
so the value really is coming across the RPC boundary and not from anywhere local.

Environment

@cloudflare/workers-types 5.20260911.1
@cloudflare/vitest-pool-workers 0.22.0
wrangler 4.131.0
typescript 6.0.3 (strict)
compatibility_date 2026-08-22

Notes

Adding ReadonlyMap / ReadonlySet branches mirroring the existing ReadonlyArray branch
looks like the natural shape, but I have not tested a patch, so I can't say whether that alone
resolves it without side effects on inference.

Possibly related: #5804 touches the same mapped-type branch of Serializable.

Activity

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

Metadata

Metadata

Labels

typesRelated to @cloudflare/workers-types

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions