From 9f69631c1211ce870ec7858d9def4b3751f02a52 Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Thu, 20 Aug 2026 09:11:41 -0500 Subject: [PATCH 1/4] fix: align RpcPromise construction with result elision Apply ElideStub -- the same transformation Result applies to a declared stub return -- to the RpcPromise constructor signature, so constructing from a promised stub produces exactly the type a method returning that stub would. The parameter accepts Promise>: the payload itself or, for stubable payloads, a stub of it. NoInfer keeps the stub arm out of inference, so an inferred T is always the promise's own resolution type; stubs of non-stubable payloads are rejected because ElideStub wouldn't elide them. Type tests pin constructor/method equivalence for bare, callable, plain-interface, and union payloads. (Extracted from the pre-squash feat/result-stub-elision branch: the constructor-alignment portion of its history that PR #253 didn't include.) --- __type-tests__/stub-elision.test.ts | 33 +++++++++++++++++++++++++---- src/index.ts | 9 +++++--- src/types.d.ts | 10 +++++++++ 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/__type-tests__/stub-elision.test.ts b/__type-tests__/stub-elision.test.ts index 1b5509f3..ec5dc82e 100644 --- a/__type-tests__/stub-elision.test.ts +++ b/__type-tests__/stub-elision.test.ts @@ -1,7 +1,7 @@ // Declared stub returns/properties (`Promise>`, `RpcStub`) must produce the same -// `RpcPromise` as returning the payload directly (`Promise`). Plain-interface stubs are -// the exception: they are NOT elided, because `RpcPromise` only awaits back to a stub when -// `U` is Stubable. +// `RpcPromise` as returning the payload directly (`Promise`), matching what +// `new RpcPromise(Promise.resolve(stub))` produces. Plain-interface stubs are the exception: +// they are NOT elided, because `RpcPromise` only awaits back to a stub when `U` is Stubable. import { RpcPromise, RpcStub, RpcTarget } from "../src/index.js" import type { Stubable } from "../src/types.js" import { expectAssignable, expectType, type Equal, type Expect } from "./helpers.js" @@ -76,9 +76,34 @@ type _CallableStubElides = Expect> type _AwaitedFnStub = Expect, RpcStub>> expectAssignable>(fnViaStub(4)) -// 7. Union payloads distribute: `Promise | null>` returns elide the stub arm. +// 7. Constructor/method equivalence: wrapping a promised stub yourself produces exactly the +// same type as a method declared to return the stub, for every payload shape. +declare const counterStub: RpcStub +const constructed = new RpcPromise(Promise.resolve(counterStub)) +type _ConstructorMatchesMethodReturn = Expect> + +// 7b. Explicitly annotating the payload type still compiles. +const explicit: RpcPromise = new RpcPromise(Promise.resolve(counterStub)) +void explicit + +// 7c. Callable stubs elide in the constructor too. +declare const formatterStub: RpcStub +const constructedFn = new RpcPromise(Promise.resolve(formatterStub)) +type _CallableCtorMatchesMethodReturn = Expect> + +// 7d. Plain-interface stubs are not elided in either form, and the two forms agree. +declare const plainStub: RpcStub +const constructedPlain = new RpcPromise(Promise.resolve(plainStub)) +const plainViaMethod = api.getApi() +type _PlainCtorMatchesMethodReturn = Expect> + +// 7e. Union payloads distribute identically in both forms. +declare const maybePromise: Promise | null> +const constructedMaybe = new RpcPromise(maybePromise) const maybeViaMethod = api.maybeStub() +type _UnionCtorMatchesMethodReturn = Expect> api.consumeMaybe(maybeViaMethod) +api.consumeMaybe(constructedMaybe) // 8. map() over a declared `RpcStub[]` return: the callback placeholder is `T`-shaped, // so pipelined calls on elements typecheck. diff --git a/src/index.ts b/src/index.ts index a66a7d92..6f6ed30b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,8 +6,8 @@ import { RpcTarget as RpcTargetImpl, RpcStub as RpcStubImpl, RpcPromise as RpcPr import { serialize, deserialize, EncodingLevel } from "./serialize.js"; import { RpcTransport, RpcTransportWithCustomEncoding, AnyRpcTransport, RpcSession as RpcSessionImpl, RpcSessionOptions } from "./rpc.js"; import { RpcLimits, DEFAULT_LIMITS, DEFAULT_MAX_DEPTH } from "./serialize.js"; -import { RpcTargetBranded, RpcCompatible, Stub, type RpcPromise as RpcPromiseType, - __RPC_TARGET_BRAND } from "./types.js"; +import { RpcTargetBranded, RpcCompatible, Stub, ElideStub, PayloadOrStub, + type RpcPromise as RpcPromiseType, __RPC_TARGET_BRAND } from "./types.js"; import { newWebSocketRpcSession as newWebSocketRpcSessionImpl, newWorkersWebSocketRpcResponse, WebSocketTransport } from "./websocket.js"; import { newHttpBatchRpcSession as newHttpBatchRpcSessionImpl, @@ -70,7 +70,10 @@ export const RpcStub: { */ export type RpcPromise> = RpcPromiseType; export const RpcPromise: { - new >(value: Promise>): RpcPromise; + // The return type applies `ElideStub` — the same transformation `Result` applies to a + // declared stub return — so constructing from a promised stub produces exactly the type a + // method returning that stub would. See `PayloadOrStub` for what the promise may resolve to. + new >(value: Promise>): RpcPromiseType>; } = RpcPromiseImpl; /** diff --git a/src/types.d.ts b/src/types.d.ts index d988a1d7..7e4d75d8 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -184,11 +184,21 @@ export type RpcPromise = // stubified record. The payload check is deliberately non-distributive (`[U] extends [...]`). // `Stub` is not elided either: `[any] extends [Stubable]` is true, so without the `IsAny` // guard an `any`-payload stub would lose its stub surface. +// Also used by the `RpcPromise` constructor signature (index.ts), which applies exactly this +// transformation so constructing from a promised stub matches the method-return type. export type ElideStub = T extends StubBase ? (IsAny extends true ? T : [U] extends [Stubable] ? U : T) : T; +// What the promise given to `new RpcPromise(...)` may resolve to: the payload itself, or — +// for stubable payloads — a stub of it. `NoInfer` keeps the stub arm out of inference, so an +// inferred `T` is always the promise's own resolution type; the arm only matters when `T` is +// explicitly annotated (`new RpcPromise(promiseOfStub)`). Stubs of non-stubable +// payloads are deliberately rejected: `ElideStub` wouldn't elide those, so accepting one would +// claim the promise awaits to a stubified record while the runtime resolves to a stub. +export type PayloadOrStub = T | NoInfer>>; + // Type for method return or property on an RPC interface. // - Stubable types are replaced by stubs. // - RpcCompatible types are passed by value, with stubable types replaced by stubs From f7d093eefd22ade32f47bf033ebf6ab4cf876f63 Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Thu, 20 Aug 2026 08:21:26 -0500 Subject: [PATCH 2/4] fix: infer inline object literals with methods in the RpcPromise constructor new RpcPromise(Promise.resolve({ x: 1, f() { return 1 } })) failed to type-check: a literal with a method is context-sensitive, and the stub arm of the constructor's Promise> parameter poisons the contextual type, collapsing Promise.resolve's inference to never. The same value predeclared in a const, or with an explicit type argument, worked fine. The constructor type is now an overload pair. The first overload takes a plain Promise -- no stub arm in the contextual type -- which is the one context-sensitive arguments are typed against. Since PayloadOrStub's stub arm is NoInfer anyway, both overloads infer identically; the second (the previous signature) matters only when T is explicitly annotated and the payload is a stub. Type tests cover the inline literal, the predeclared equivalent, explicit-type-argument-with-stub-payload, and a promise for a target-or-stub union. --- __type-tests__/rpc-base-cases.test.ts | 20 ++++++++++++++++++++ src/index.ts | 9 +++++++++ 2 files changed, 29 insertions(+) diff --git a/__type-tests__/rpc-base-cases.test.ts b/__type-tests__/rpc-base-cases.test.ts index 262ec60c..92056353 100644 --- a/__type-tests__/rpc-base-cases.test.ts +++ b/__type-tests__/rpc-base-cases.test.ts @@ -161,6 +161,26 @@ expectType>(new RpcPromise(Promise.reject(n const promisedFromStub = new RpcPromise(Promise.resolve(pointStub)) type _PromisedFromStubInfersTarget = Expect>> +// An inline object literal with a method is context-sensitive (the method's return type must be +// inferred), which is only compatible with the constructor's plain-promise overload; it must +// infer without an explicit type argument. Its methods' return types stay un-widened (`1`, not +// `number`), hence assignable-to rather than exactly-equal-to the widened shape. +const promisedFromInline = new RpcPromise(Promise.resolve({ value: 1, next() { return 1 } })) +expectAssignable>(promisedFromInline) + +// The same shape predeclared widens normally and infers exactly. +const predeclaredShape = { value: 1, next() { return 1 } } +expectType>( + new RpcPromise(Promise.resolve(predeclaredShape))) + +// An explicit type argument combines with a stub payload (the constructor's fallback overload). +expectType>(new RpcPromise(Promise.resolve(pointStub))) + +// A promise for a union of the target and its stub still infers the target type. +declare const targetOrStubPromise: Promise> +const promisedFromUnion = new RpcPromise(targetOrStubPromise) +type _PromisedFromUnionInfersTarget = Expect>> + async function assertAwaitedConstructedPromiseShapes() { const target = await new RpcPromise(Promise.resolve(new PointTarget())) expectType>(target) diff --git a/src/index.ts b/src/index.ts index 6f6ed30b..0a45747c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -73,6 +73,15 @@ export const RpcPromise: { // The return type applies `ElideStub` — the same transformation `Result` applies to a // declared stub return — so constructing from a promised stub produces exactly the type a // method returning that stub would. See `PayloadOrStub` for what the promise may resolve to. + // + // Two overloads, for inference reasons. A context-sensitive argument — e.g. + // `Promise.resolve({f() { ... }})`, where the method's return type must be inferred — is + // contextually typed against the first overload only, and a contextual type containing a + // `Stub` arm collapses such an argument's inference. The first overload therefore keeps its + // parameter a plain `Promise`. Since `PayloadOrStub`'s stub arm is `NoInfer` anyway, both + // overloads infer identically; the second one matters only when `T` is explicitly annotated + // and the payload is a stub, e.g. `new RpcPromise(promiseOfStub)`. + new >(value: Promise): RpcPromiseType>; new >(value: Promise>): RpcPromiseType>; } = RpcPromiseImpl; From 708320e85838f579528bbba7a8fca09ac57e0ebe Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Thu, 20 Aug 2026 09:32:32 -0500 Subject: [PATCH 3/4] chore: trim redundant type-test coverage and add the constructor changeset - Drop the explicit-type-argument equivalence case from stub-elision (rpc-base-cases pins the same overload with a strict expectType) and the consumeMaybe(constructedMaybe) call already implied by the strict Equal against maybeViaMethod. - Add a changeset for the constructor typing change; changeset-bot was flagging the PR as bump-less. --- .changeset/rpc-promise-ctor-elision.md | 5 +++++ __type-tests__/stub-elision.test.ts | 12 ++++-------- 2 files changed, 9 insertions(+), 8 deletions(-) create mode 100644 .changeset/rpc-promise-ctor-elision.md diff --git a/.changeset/rpc-promise-ctor-elision.md b/.changeset/rpc-promise-ctor-elision.md new file mode 100644 index 00000000..ea4c8143 --- /dev/null +++ b/.changeset/rpc-promise-ctor-elision.md @@ -0,0 +1,5 @@ +--- +"capnweb": patch +--- + +The `RpcPromise` constructor now applies the same stub elision as method result types: wrapping a `Promise>` produces the same `RpcPromise` a method declared to return that stub would, plain-interface stub payloads keep their stub type, and promises resolving to inline object literals with methods now infer correctly. diff --git a/__type-tests__/stub-elision.test.ts b/__type-tests__/stub-elision.test.ts index ec5dc82e..d44fd1b0 100644 --- a/__type-tests__/stub-elision.test.ts +++ b/__type-tests__/stub-elision.test.ts @@ -82,28 +82,24 @@ declare const counterStub: RpcStub const constructed = new RpcPromise(Promise.resolve(counterStub)) type _ConstructorMatchesMethodReturn = Expect> -// 7b. Explicitly annotating the payload type still compiles. -const explicit: RpcPromise = new RpcPromise(Promise.resolve(counterStub)) -void explicit - -// 7c. Callable stubs elide in the constructor too. +// 7b. Callable stubs elide in the constructor too. (An explicit type argument with a stub +// payload is covered in rpc-base-cases.test.ts.) declare const formatterStub: RpcStub const constructedFn = new RpcPromise(Promise.resolve(formatterStub)) type _CallableCtorMatchesMethodReturn = Expect> -// 7d. Plain-interface stubs are not elided in either form, and the two forms agree. +// 7c. Plain-interface stubs are not elided in either form, and the two forms agree. declare const plainStub: RpcStub const constructedPlain = new RpcPromise(Promise.resolve(plainStub)) const plainViaMethod = api.getApi() type _PlainCtorMatchesMethodReturn = Expect> -// 7e. Union payloads distribute identically in both forms. +// 7d. Union payloads distribute identically in both forms. declare const maybePromise: Promise | null> const constructedMaybe = new RpcPromise(maybePromise) const maybeViaMethod = api.maybeStub() type _UnionCtorMatchesMethodReturn = Expect> api.consumeMaybe(maybeViaMethod) -api.consumeMaybe(constructedMaybe) // 8. map() over a declared `RpcStub[]` return: the callback placeholder is `T`-shaped, // so pipelined calls on elements typecheck. From 69de617d9418dabe8f56a9d1a8054731955cc29f Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Thu, 20 Aug 2026 09:51:54 -0500 Subject: [PATCH 4/4] test: pin RpcPromise constructor elision for ValidatedStub payloads A ValidatedStub payload elides in the constructor (branded targets) or keeps its stub type (plain interfaces) only because ValidatedStub structurally matches capnweb's StubBase, which ElideStub keys on -- capnweb-validate maintains its own copy of that shape. Pin both cases so drift in either package can't silently change the constructor's type. --- __type-tests__/capnweb-validate.test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/__type-tests__/capnweb-validate.test.ts b/__type-tests__/capnweb-validate.test.ts index bb9c145f..11925af7 100644 --- a/__type-tests__/capnweb-validate.test.ts +++ b/__type-tests__/capnweb-validate.test.ts @@ -1,4 +1,4 @@ -import { RpcTarget, type RpcCompatible } from "../src/index.js" +import { RpcPromise, RpcTarget, type RpcCompatible } from "../src/index.js" import { validateStub, type ValidatedStub } from "../packages/capnweb-validate/src/index.js" import { expectAssignable, expectType, type Equal, type Expect } from "./helpers.js" @@ -89,6 +89,18 @@ let chainApi = validateStub(rawStub) const chained = chainApi.chain() type _RpcPromiseNormalizes = Expect> +// The RpcPromise constructor applies the same elision to ValidatedStub payloads. This holds +// because ValidatedStub structurally matches capnweb's StubBase, which ElideStub keys on — +// pin it so drift in either package's stub shape can't silently change the constructor's type. +declare const validatedCounter: ValidatedStub +const ctorFromValidated = new RpcPromise(Promise.resolve(validatedCounter)) +type _CtorElidesValidatedStub = Expect>> + +declare const validatedPlain: ValidatedStub +const ctorFromValidatedPlain = new RpcPromise(Promise.resolve(validatedPlain)) +type _CtorKeepsValidatedPlainStub = + Expect>>> + const plainStubPromise = stubApi.getPlain() async function assertValidatedStubShapes() {