Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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.
14 changes: 13 additions & 1 deletion __type-tests__/capnweb-validate.test.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -89,6 +89,18 @@ let chainApi = validateStub<ChainApi>(rawStub)
const chained = chainApi.chain()
type _RpcPromiseNormalizes = Expect<Equal<typeof chained, typeof viaTarget>>

// 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<Counter>
const ctorFromValidated = new RpcPromise(Promise.resolve(validatedCounter))
type _CtorElidesValidatedStub = Expect<Equal<typeof ctorFromValidated, RpcPromise<Counter>>>

declare const validatedPlain: ValidatedStub<Api>
const ctorFromValidatedPlain = new RpcPromise(Promise.resolve(validatedPlain))
type _CtorKeepsValidatedPlainStub =
Expect<Equal<typeof ctorFromValidatedPlain, RpcPromise<ValidatedStub<Api>>>>

const plainStubPromise = stubApi.getPlain()

async function assertValidatedStubShapes() {
Expand Down
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