Skip to content

fix: elide stubs in RPC result types so Promise<RpcStub<T>> returns match Promise<T> - #251

Draft
ndisidore wants to merge 1 commit into
feat/from-promisefrom
feat/result-stub-elision
Draft

fix: elide stubs in RPC result types so Promise<RpcStub<T>> returns match Promise<T>#251
ndisidore wants to merge 1 commit into
feat/from-promisefrom
feat/result-stub-elision

Conversation

@ndisidore

@ndisidore ndisidore commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Declaring a method as viaStub(): Promise<RpcStub<T>> now yields the same RpcPromise<T> as declaring Promise<T>, matching what new RpcPromise(promise) infers. This resolves @kentonv's | Stub<T> question on #242. The two forms had to converge on eliding because the declared-stub form is broken on main: RpcPromise<RpcStub<T>> can't be passed as a pipelined argument and its awaited value isn't assignable to RpcStub<T>. Only pipelining typechecks, which is why nobody noticed.

The culprit is the string-keyed __RPC_TARGET_BRAND. It survives Provider<T>'s key mapping, so a stub of an RpcTarget (or function) matches Stubable and gets wrapped a second time. Fixed by reordering Stubify's arms and adding eliding arms to Result, mirrored in capnweb-validate.

Plain-interface stubs (RpcStub<PlainApi>) are deliberately not elided: RpcPromise<U> only awaits back to a stub when U is stubable, so eliding those would break the shape that already works. There's a pinning test for it.

Not touched here: hand-written RpcPromise<RpcStub<T>> annotations still misbehave (the library just never produces that type now), and the constructor still elides plain-interface stubs where method returns don't. Happy to add the same guard there if we want them identical.

Stacked on #242 because the equivalence test needs the constructor from that PR.

@changeset-bot

changeset-bot Bot commented Aug 19, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 05c32ea

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
capnweb Patch
capnweb-validate Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@ask-bonk

This comment was marked as low quality.

@ask-bonk

This comment was marked as low quality.

@pkg-pr-new

pkg-pr-new Bot commented Aug 19, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/capnweb@251

commit: 05c32ea

@ndisidore
ndisidore force-pushed the feat/result-stub-elision branch from 4abffdd to 6362382 Compare August 19, 2026 02:20
…atch Promise<T>

A method declared to return Promise<RpcStub<T>> (or a property typed
RpcStub<T>) previously produced RpcPromise<RpcStub<T>>, a broken type
that could neither be awaited into an RpcStub<T> nor passed as a
pipelined argument. Root cause: the string-keyed __RPC_TARGET_BRAND
survives Provider<T>'s key mapping (which only excludes
symbol | keyof StubBase), so Stub<T> of a branded or callable T
structurally matches Stubable, shadowing the intended StubBase handling
in both Stubify and Result.

Result (and the capnweb-validate mirror, StubResult) now elides the
stub wrapper for Stubable payloads, so declared-stub returns produce
the exact same RpcPromise<T> as returning the payload directly — and
the same type the RpcPromise constructor produces for a promised stub.
Plain-interface stubs (RpcStub<PlainApi>) are deliberately NOT elided:
RpcPromise<U> only awaits back to Stub<U> when U extends Stubable, so
eliding those would change the awaited value from a stub to a stubified
record, breaking today's working shape. Stubify's arms are reordered to
[Promise, StubBase, Stubable] so existing stubs pass through instead of
double-wrapping, and promise-backed stubs resolve through the Promise
arm.

The eliding arm intentionally omits an RpcCompatible<R> re-check:
evaluating RpcCompatible<R> there recurses through its Stub<Stubable>
member back into Result, tripping TS2615 circularity errors in mapped
types. Anything matching our StubBase already had the constraint
enforced where the stub type was formed.

Documented but not fixed here:
- A hand-written RpcPromise<RpcStub<T>> annotation still takes the
  RpcPromise alias's own Stubable arm (awaits to Stub<Stub<T>>); the
  library no longer produces that type, and fixing it inside the alias
  risks the Result/RpcPromise identity short-circuit.
- Native workers-types stubs (Rpc.Stub) are unaffected: their StubBase
  lacks onRpcBroken, so they never match our StubBase<infer U>. This
  consistency gap is unchanged from before.
- The RpcPromise constructor's Promise<T | Stub<T>> union also elides
  plain-interface stubs (new RpcPromise(Promise.resolve(plainIfaceStub))
  infers RpcPromise<PlainApi>), which method returns deliberately do
  not. Pre-existing on the parent branch; worth raising in review.

The eliding arms guard `any` payloads explicitly (IsAny): `[any] extends
[Stubable]` is true, so without the guards RpcStub<any> results collapsed
to UnknownResult (awaiting to unknown), and capnweb-validate's
StubResult<any> collapsed to Promise<unknown> & StubBase<unknown> even for
plain Promise<any> returns. Both now keep their stub surface.
@ndisidore
ndisidore force-pushed the feat/result-stub-elision branch from 6362382 to 05c32ea Compare August 19, 2026 11:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant