Skip to content

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

Draft
ndisidore wants to merge 5 commits 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 5 commits 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.

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: bb7c807

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

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

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: bb7c807

@ndisidore
ndisidore force-pushed the feat/result-stub-elision branch 2 times, most recently from 6362382 to 05c32ea Compare August 19, 2026 11:23
…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.
…ruction with result elision

- Exclude __RPC_TARGET_BRAND (and workers brands in capnweb-validate) from
  Provider/ValidatedStub key mapping so the string-keyed brand no longer
  leaks onto stub surfaces.
- Extract the Result stub elision into ElideStub and apply it to the
  RpcPromise constructor signature, so constructing from a promised stub
  produces exactly the type a method returning that stub would.
- Make validate's StubResult distributive so unions like
  ValidatedStub<T> | null elide and never stays never.
- Bump the changeset to minor: explicit RpcPromise<RpcStub<T>> annotations
  on elided results should now be written RpcPromise<T>.
@ndisidore
ndisidore force-pushed the feat/result-stub-elision branch from 05c32ea to 3bd5a25 Compare August 19, 2026 21:14
All provably behavior-preserving, each verified independently against the
type-test suite (strict Equal<> assertions) and the full build:

- Simplify IsUnknown: the inner bracket check was a dead branch (it never
  excluded `any`; call sites that care check IsAny first).
- Drop MethodOrProperty's IsAny guard: Awaited<any> is `any` and Result
  already opens with the same check.
- Drop Result's IsUnknown arm: Result<unknown> falls through to the
  RpcCompatible arm and yields the structurally identical RpcPromise<unknown>.
- Drop Result's Stubable arm: Stubable is a member of the RpcCompatible
  union, so stubable returns produce the same RpcPromise<R> one arm later.
- Delete UnknownResult in favor of RpcPromise<unknown>, which expands to the
  same type and keeps Result's arms on a single alias for identity
  short-circuiting.

Also document in capnweb-validate why MaybeCallableStub deliberately repeats
StubMethodOrProperty's function arm: delegating tips ValidatedStub's
recursive instantiation over TS's depth limit (TS2589).

Compiler diagnostics improve slightly: types 50,773 -> 50,194,
instantiations 199,986 -> 193,650.
…parameter

The inline `T | NoInfer<Stub<Extract<T, Stubable>>>` union was doing three
jobs at once (accept stubs, restrict to stubable payloads, stay out of
inference) with no room to explain any of them. Name it and document why
each piece is load-bearing. NoInfer's inference suppression survives the
alias: the strict Equal<> constructor-inference tests (bare, callable,
plain-interface, and union payloads) are unchanged and pass.
@ndisidore
ndisidore marked this pull request as ready for review August 19, 2026 22:39
@ndisidore
ndisidore marked this pull request as draft August 19, 2026 22:50
Comment thread __tests__/workerd.test.ts
let stub = new NativeRpcStub(new RpcStub(new JsCounter()));
// Cast: now that the `__RPC_TARGET_BRAND` no longer leaks onto stub surfaces, a userspace
// stub doesn't statically match workers-types' `Stubable` (runtime interop still works).
let stub = new NativeRpcStub(<any>new RpcStub(new JsCounter()));

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ugh this is probably a non-starter

  • capnweb stub → new NativeRpcStub(...): needs now

but its because runtime suffers from the same poised branded stub issue

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