Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rpc-promise-ctor-elision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"capnweb": patch
---

The `RpcPromise` constructor now applies the same stub elision as method result types: wrapping a `Promise<RpcStub<T>>` produces the same `RpcPromise<T>` 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.
20 changes: 20 additions & 0 deletions __type-tests__/rpc-base-cases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,26 @@ expectType<RpcPromise<PointTarget>>(new RpcPromise<PointTarget>(Promise.reject(n
const promisedFromStub = new RpcPromise(Promise.resolve(pointStub))
type _PromisedFromStubInfersTarget = Expect<Equal<typeof promisedFromStub, RpcPromise<PointTarget>>>

// 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<RpcPromise<{ value: number, next(): number }>>(promisedFromInline)

// The same shape predeclared widens normally and infers exactly.
const predeclaredShape = { value: 1, next() { return 1 } }
expectType<RpcPromise<{ value: number, next(): number }>>(
new RpcPromise(Promise.resolve(predeclaredShape)))

// An explicit type argument combines with a stub payload (the constructor's fallback overload).
expectType<RpcPromise<PointTarget>>(new RpcPromise<PointTarget>(Promise.resolve(pointStub)))

// A promise for a union of the target and its stub still infers the target type.
declare const targetOrStubPromise: Promise<PointTarget | RpcStub<PointTarget>>
const promisedFromUnion = new RpcPromise(targetOrStubPromise)
type _PromisedFromUnionInfersTarget = Expect<Equal<typeof promisedFromUnion, RpcPromise<PointTarget>>>

async function assertAwaitedConstructedPromiseShapes() {
const target = await new RpcPromise(Promise.resolve(new PointTarget()))
expectType<RpcStub<PointTarget>>(target)
Expand Down
29 changes: 25 additions & 4 deletions __type-tests__/stub-elision.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Declared stub returns/properties (`Promise<RpcStub<T>>`, `RpcStub<T>`) must produce the same
// `RpcPromise<T>` as returning the payload directly (`Promise<T>`). Plain-interface stubs are
// the exception: they are NOT elided, because `RpcPromise<U>` only awaits back to a stub when
// `U` is Stubable.
// `RpcPromise<T>` as returning the payload directly (`Promise<T>`), matching what
// `new RpcPromise(Promise.resolve(stub))` produces. Plain-interface stubs are the exception:
// they are NOT elided, because `RpcPromise<U>` 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"
Expand Down Expand Up @@ -76,8 +76,29 @@ type _CallableStubElides = Expect<Equal<typeof fnViaStub, typeof fnViaTarget>>
type _AwaitedFnStub = Expect<Equal<Awaited<typeof fnViaStub>, RpcStub<Formatter>>>
expectAssignable<Promise<string>>(fnViaStub(4))

// 7. Union payloads distribute: `Promise<RpcStub<T> | 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<Counter>
const constructed = new RpcPromise(Promise.resolve(counterStub))
type _ConstructorMatchesMethodReturn = Expect<Equal<typeof constructed, typeof viaStub>>

// 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<Formatter>
const constructedFn = new RpcPromise(Promise.resolve(formatterStub))
type _CallableCtorMatchesMethodReturn = Expect<Equal<typeof constructedFn, typeof fnViaStub>>

// 7c. Plain-interface stubs are not elided in either form, and the two forms agree.
declare const plainStub: RpcStub<PlainApi>
const constructedPlain = new RpcPromise(Promise.resolve(plainStub))
const plainViaMethod = api.getApi()
type _PlainCtorMatchesMethodReturn = Expect<Equal<typeof constructedPlain, typeof plainViaMethod>>

// 7d. Union payloads distribute identically in both forms.
declare const maybePromise: Promise<RpcStub<Counter> | null>
const constructedMaybe = new RpcPromise(maybePromise)
const maybeViaMethod = api.maybeStub()
type _UnionCtorMatchesMethodReturn = Expect<Equal<typeof constructedMaybe, typeof maybeViaMethod>>
api.consumeMaybe(maybeViaMethod)

// 8. map() over a declared `RpcStub<T>[]` return: the callback placeholder is `T`-shaped,
Expand Down
18 changes: 15 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -70,7 +70,19 @@ export const RpcStub: {
*/
export type RpcPromise<T extends RpcCompatible<T>> = RpcPromiseType<T>;
export const RpcPromise: {
new <T extends RpcCompatible<T>>(value: Promise<T | Stub<T>>): RpcPromise<T>;
// 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<T>`. 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<Counter>(promiseOfStub)`.
new <T extends RpcCompatible<T>>(value: Promise<T>): RpcPromiseType<ElideStub<T>>;
new <T extends RpcCompatible<T>>(value: Promise<PayloadOrStub<T>>): RpcPromiseType<ElideStub<T>>;
} = <any>RpcPromiseImpl;

/**
Expand Down
10 changes: 10 additions & 0 deletions src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,11 +184,21 @@ export type RpcPromise<T> =
// stubified record. The payload check is deliberately non-distributive (`[U] extends [...]`).
// `Stub<any>` 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> =
T extends StubBase<infer U>
? (IsAny<U> extends true ? T : [U] extends [Stubable] ? U : T)
: T;

// What the promise given to `new RpcPromise<T>(...)` 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<Counter>(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> = T | NoInfer<Stub<Extract<T, Stubable>>>;

// 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
Expand Down
Loading