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.
Summary
A Durable Object RPC method that returns a
ReadonlyMapor aReadonlySetfails theT extends Rpc.Serializable<T>constraint, even though the value crosses the RPC boundaryfine at runtime. Because the constraint failure makes the caller-side type
never, andneveris assignable to everything,tscreports no error at the definition site or atany call site. The method just silently becomes unusable.
ReadonlyArrayis already handled;ReadonlyMapandReadonlySetlook like an oversightrather than a decision.
Where
types/defines/rpc.d.ts:ReadonlyMapis not assignable toMap(it lacksset/delete/clear), and it does notmatch the mapped-type branch either, so it falls through the whole union. Same for
ReadonlySet.Repro 1 — the constraint
Repro 2 — the failure is silent
There is no diagnostic pointing at
frozen. In my case the only thing that caught it was@typescript-eslint/await-thenablefiring onawait stub.frozen().Repro 3 — the runtime accepts the value
ReadonlyMaphas no runtime representation, so the value aReadonlyMap-typed methodreturns is the same
Mapthat aMap-typed method returns. Run with@cloudflare/vitest-pool-workers: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@cloudflare/vitest-pool-workerswranglertypescriptstrict)compatibility_dateNotes
Adding
ReadonlyMap/ReadonlySetbranches mirroring the existingReadonlyArraybranchlooks 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.