diff --git a/.changeset/rpc-promise-ctor-elision.md b/.changeset/rpc-promise-ctor-elision.md new file mode 100644 index 0000000..ea4c814 --- /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__/capnweb-validate.test.ts b/__type-tests__/capnweb-validate.test.ts index bb9c145..11925af 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() { diff --git a/__type-tests__/rpc-base-cases.test.ts b/__type-tests__/rpc-base-cases.test.ts index 262ec60..9205635 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/__type-tests__/stub-elision.test.ts b/__type-tests__/stub-elision.test.ts index 1b5509f..d44fd1b 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,8 +76,29 @@ 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. 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> + +// 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> + +// 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) // 8. map() over a declared `RpcStub[]` return: the callback placeholder is `T`-shaped, diff --git a/src/index.ts b/src/index.ts index a66a7d9..0a45747 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,19 @@ 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. + // + // 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; /** diff --git a/src/types.d.ts b/src/types.d.ts index d988a1d..7e4d75d 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