diff --git a/.changeset/result-stub-elision.md b/.changeset/result-stub-elision.md new file mode 100644 index 00000000..d23953e3 --- /dev/null +++ b/.changeset/result-stub-elision.md @@ -0,0 +1,6 @@ +--- +"capnweb": minor +"capnweb-validate": minor +--- + +Fixed methods declared to return `Promise>` producing broken stub-of-stub result types; they now type the same as `Promise`. If you annotated such a result as `RpcPromise>`, write `RpcPromise` instead. diff --git a/__type-tests__/capnweb-validate.test.ts b/__type-tests__/capnweb-validate.test.ts index 58e34aa7..bb9c145f 100644 --- a/__type-tests__/capnweb-validate.test.ts +++ b/__type-tests__/capnweb-validate.test.ts @@ -1,6 +1,6 @@ import { RpcTarget, type RpcCompatible } from "../src/index.js" import { validateStub, type ValidatedStub } from "../packages/capnweb-validate/src/index.js" -import { expectAssignable, expectType, type Expect } from "./helpers.js" +import { expectAssignable, expectType, type Equal, type Expect } from "./helpers.js" class Counter extends RpcTarget { increment(by: number): number { @@ -33,9 +33,99 @@ api.getPair().then((pair) => { void mutablePair }) +// Stub elision mirrors the main package: a `Promise>` return for a branded +// target produces the same type as a `Promise` return, while plain-interface stubs keep +// the non-elided shape (they only await back to a stub when NOT elided). +type Formatter = (x: number) => string + +interface StubReturningApi { + viaTarget(): Promise + viaStub(): Promise> + viaFn(): Promise + viaFnStub(): Promise> + getPlain(): Promise> + getAnyStub(): Promise> + getAny(): Promise + getUnknown(): Promise + maybeStub(): Promise | null> + consumeMaybe(counter: ValidatedStub | null): Promise + dies(): Promise +} + +// The brand-leak fix, mirrored: a validated stub of a branded target must not itself look +// branded, or `Stubify` would double-wrap it. +type _NoBrandLeak = Expect< + Equal extends { readonly __RPC_TARGET_BRAND: never } ? true : false, false> +> + +let stubApi = validateStub(rawStub) + +const viaTarget = stubApi.viaTarget() +const viaStub = stubApi.viaStub() +type _ValidatedStubElides = Expect> +expectAssignable>(viaStub.increment(2)) + +// Callable stubs elide too. +const fnViaTarget = stubApi.viaFn() +const fnViaStub = stubApi.viaFnStub() +type _ValidatedCallableStubElides = Expect> + +// A `never`-returning method stays `never` instead of matching the promise-normalization arm +// with `U = unknown`. +const neverResult = stubApi.dies() +type _NeverStaysNever = Expect> + +// Elision distributes over unions, so a `ValidatedStub | null` result still passes as a +// pipelined argument. +const maybe = stubApi.maybeStub() +stubApi.consumeMaybe(maybe) + +// Promise-backed stub results normalize: re-declaring a method as returning another method's +// result type produces that same type. +interface ChainApi { + chain(): typeof viaTarget +} +let chainApi = validateStub(rawStub) +const chained = chainApi.chain() +type _RpcPromiseNormalizes = Expect> + +const plainStubPromise = stubApi.getPlain() + +async function assertValidatedStubShapes() { + const awaitedCounter = await viaStub + expectAssignable>(awaitedCounter.increment(1)) + + // Plain-interface stubs keep the wrapper: awaiting still yields the stub itself. + const inner: ValidatedStub = await plainStubPromise + expectAssignable>(inner.getCounter().increment(1)) + + // Union elision: awaiting yields the payload stub or null. + const maybeCounter = await maybe + if (maybeCounter !== null) { + expectAssignable>(maybeCounter.increment(1)) + } else { + expectType(maybeCounter) + } + + // `any` and `unknown` payloads must keep the full stub-result surface: `[any] extends [X]` + // is true for any `X`, so without the IsAny guards these would collapse to + // `Promise & StubBase`. + const anyStubResult = stubApi.getAnyStub() + const anyResult = stubApi.getAny() + expectAssignable(anyStubResult) + expectAssignable(anyResult) + expectAssignable(stubApi.getUnknown()) + anyStubResult.dup() + anyResult.onRpcBroken((_error) => {}) +} + +void assertValidatedStubShapes + // @ts-expect-error wrong method name api.missing() // @ts-expect-error wrong argument type counter.increment("1") // @ts-expect-error array elements must be numbers api.sum(["1"]) +// @ts-expect-error pipelined methods keep signatures on elided stub returns +viaStub.increment("2") diff --git a/__type-tests__/stub-elision.test.ts b/__type-tests__/stub-elision.test.ts new file mode 100644 index 00000000..1b5509f3 --- /dev/null +++ b/__type-tests__/stub-elision.test.ts @@ -0,0 +1,131 @@ +// 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. +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" + +class Counter extends RpcTarget { + increment(by: number): number { + return by + } + + get value(): number { + return 0 + } +} + +type Formatter = (x: number) => string + +interface PlainApi { + ping(): number + echo(name: string): Promise +} + +interface ElisionApi { + viaTarget(): Promise + viaStub(): Promise> + counterProp: RpcStub + viaFn(): Promise + viaFnStub(): Promise> + wrapped(): Promise<{ s: RpcStub }> + listStubs(): Promise[]> + consumeCounter(counter: RpcStub): Promise + getApi(): Promise> + getAnyStub(): Promise> + anyStubProp: RpcStub + maybeStub(): Promise | null> + consumeMaybe(counter: RpcStub | null): Promise +} + +// Stubs keep the string-keyed brand on their surface (for workers-types interop), so they +// match `Stubable` — harmless, because `Stubify`/`Result` check `StubBase` before `Stubable`. +// That ordering is the actual double-stubification fix; it protects callable stubs too. +type _BrandedStubIsStubable = Expect extends Stubable ? true : false, true>> +type _CallableStubIsStillStubable = Expect extends Stubable ? true : false, true>> + +declare const api: RpcStub + +// 1. A `Promise>` return is indistinguishable from a `Promise` return. +const viaTarget = api.viaTarget() +const viaStub = api.viaStub() +type _StubReturnMatchesTargetReturn = Expect> +expectType>(viaStub) + +// 2. Both forms can be passed as pipelined RPC arguments (previously TS2345 for viaStub). +api.consumeCounter(viaTarget) +api.consumeCounter(viaStub) + +// 3. Awaiting yields a single stub, not a stub-of-stub (previously TS2322). +type _AwaitedViaStub = Expect, RpcStub>> + +// Pipelining on the elided promise works like any other RpcPromise. +expectAssignable>(viaStub.increment(3)) +expectAssignable>(viaStub.value) +viaStub.onRpcBroken((_error) => {}) + +// 5. An interface property typed `RpcStub` elides identically. +const propPromise = api.counterProp +type _PropertyElides = Expect> + +// 6. Callable stubs (`RpcStub<(x: number) => string>`) elide too — the second Stubable path. +const fnViaTarget = api.viaFn() +const fnViaStub = api.viaFnStub() +type _CallableStubElides = Expect> +type _AwaitedFnStub = Expect, RpcStub>> +expectAssignable>(fnViaStub(4)) + +// 7. Union payloads distribute: `Promise | null>` returns elide the stub arm. +const maybeViaMethod = api.maybeStub() +api.consumeMaybe(maybeViaMethod) + +// 8. map() over a declared `RpcStub[]` return: the callback placeholder is `T`-shaped, +// so pipelined calls on elements typecheck. +const mapped = api.listStubs().map((c) => c.increment(2)) +expectAssignable>(mapped) + +// 9. Self-referential stub returns compile (recursion in `Result` terminates). +declare class Node extends RpcTarget { + next(): Promise> +} +declare const nodeStub: RpcStub +const nextNode = nodeStub.next() +expectType>(nextNode) +const grandchild = nextNode.next() +expectType>(grandchild) + +// 4 & 10. Awaited shapes: stubs nested in object results stay single stubs, and +// plain-interface stubs keep their wrapper (awaiting still yields the stub itself). +async function assertAwaitedShapes() { + const counter = await viaStub + expectType>(counter) + + const wrapped = await api.wrapped() + expectType>(wrapped.s) + expectAssignable>(wrapped.s.increment(1)) + + // Plain-interface stubs are not elided: this assignment is today's working behavior and + // must keep compiling (eliding would make the awaited value a stubified record). + const s: RpcStub = await api.getApi() + expectAssignable>(s.ping()) + s.dup() + + // `RpcStub` results are not elided either: `[any] extends [Stubable]` is true, so + // without the IsAny guard these would collapse to `RpcPromise` and await to + // `unknown`, losing the stub surface. + const anyFromMethod = await api.getAnyStub() + const anyFromProp = await api.anyStubProp + expectAssignable(anyFromMethod) + expectAssignable(anyFromProp) + anyFromMethod.dup() + anyFromProp.onRpcBroken((_error) => {}) +} + +void assertAwaitedShapes + +// @ts-expect-error pipelined methods keep their signatures — increment requires a number +viaStub.increment("1") + +// @ts-expect-error methods not on Counter are not available on the elided promise +viaStub.missing() diff --git a/packages/capnweb-validate/src/internal/core.ts b/packages/capnweb-validate/src/internal/core.ts index b88cf4d6..543e3d8f 100644 --- a/packages/capnweb-validate/src/internal/core.ts +++ b/packages/capnweb-validate/src/internal/core.ts @@ -98,12 +98,17 @@ type BaseType = | Response | Headers; -type Stubify = T extends Stubable - ? ValidatedStub - : T extends Promise - ? Stubify - : T extends StubBase - ? T +// Arm ordering matters (mirrors the main package's `Stubify`): +// - `Promise` before `StubBase`: promise-backed stubs match both and must resolve through the +// Promise arm. +// - `StubBase` before `Stubable`: a stub of a callable `T` is itself callable, so it matches +// `Stubable`; checking `StubBase` first avoids double-wrapping existing stubs. +type Stubify = T extends Promise + ? Stubify + : T extends StubBase + ? T + : T extends Stubable + ? ValidatedStub : T extends Map ? Map, Stubify> : T extends Set @@ -157,10 +162,29 @@ type Unstubify = type UnstubifyAll = { [K in keyof T]: Unstubify; }; -type StubResult = Promise> & ValidatedStub & StubBase; +type IsAny = 0 extends 1 & T ? true : false; +type StubResultInner = Promise> & ValidatedStub & StubBase; +// Mirrors the main package's `Result` stub elision: a declared stub return/property collapses +// to the same type as returning the payload directly — but only for `Stubable` payloads, since +// only those await back to a stub. Distributes over unions, like the main package's `Result`, +// so e.g. `ValidatedStub | null` elides (this also makes `never` stay `never` instead of +// matching the promise arm with `U = unknown`). +// `any` needs explicit guards: `[any] extends [X]` is true for any `X`, so without them a +// `Promise` or `ValidatedStub` return would recurse through the eliding arms and +// collapse to `Promise & StubBase` instead of keeping the full stub surface. +type StubResult = + IsAny extends true ? StubResultInner + : T extends PromiseLike & StubBase ? StubResult + : T extends StubBase + ? (IsAny extends true ? StubResultInner + : [U] extends [Stubable] ? StubResult : StubResultInner) + : StubResultInner; type StubMethodOrProperty = T extends (...args: infer P) => infer R ? (...args: UnstubifyAll

) => StubResult> : StubResult>; +// Deliberately repeats `StubMethodOrProperty`'s function arm instead of delegating to it: +// the extra conditional layer of a delegation tips `ValidatedStub`'s recursive instantiation +// over TypeScript's depth limit (TS2589). type MaybeCallableStub = T extends (...args: infer P) => infer R ? (...args: UnstubifyAll

) => StubResult> : unknown; @@ -196,9 +220,14 @@ type MapCallbackReturn = export type ValidatedStub = MaybeCallableStub & (T extends object ? { - [K in Exclude>]: StubMethodOrProperty< - T[K] - >; + [K in Exclude< + keyof T, + | symbol + | "__RPC_TARGET_BRAND" + | "__WORKER_ENTRYPOINT_BRAND" + | "__DURABLE_OBJECT_BRAND" + | keyof StubBase + >]: StubMethodOrProperty; } & { map(callback: (value: MapCallbackValue>) => MapCallbackReturn): StubResult< Array diff --git a/src/types.d.ts b/src/types.d.ts index 5a595ff0..d988a1d7 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -21,7 +21,8 @@ export interface RpcTargetBranded { // `any` into inference. export type Stubable = RpcTargetBranded | ((...args: never[]) => unknown); -type IsUnknown = unknown extends T ? ([T] extends [unknown] ? true : false) : false; +// Note: also true for `any` (call sites that care check `IsAny` first). +type IsUnknown = unknown extends T ? true : false; // Types that can be passed over RPC // The reason for using a generic type here is to build the serializable subset of RPC-compatible @@ -95,11 +96,17 @@ type BaseType = | Response | Headers; // Recursively rewrite all `Stubable` types with `Stub`s, and resolve promises. +// Arm ordering matters here: +// - `Promise` must come before `StubBase`: `RpcPromise` matches both, and must resolve +// through the Promise arm rather than pass through as-is. +// - `StubBase` must come before `Stubable`: `Stub` of a callable `T` is itself callable, so +// it matches `Stubable`. Checking `StubBase` first keeps existing stubs as-is instead of +// double-wrapping them as `Stub>`. // prettier-ignore export type Stubify = - T extends Stubable ? Stub - : T extends Promise ? Stubify + T extends Promise ? Stubify : T extends StubBase ? T + : T extends Stubable ? Stub : T extends Map ? Map, Stubify> : T extends Set ? Set> : T extends [] ? [] @@ -169,6 +176,19 @@ export type RpcPromise = T extends Stubable ? Promise> & Provider & StubBase : Promise & MaybeDisposable> & Provider & StubBase; +// The elision `Result` applies to a bare stub type: unwrap `Stub` back to `T` when the +// payload is `Stubable`. Such stubs await back to a stub either way, so eliding keeps a +// declared `Promise>` return interchangeable with a `Promise` return. +// Plain-interface stubs are NOT elided: `RpcPromise` only awaits to `Stub` when +// `U extends Stubable`, so eliding those would change the awaited type from a stub to a +// 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. +export type ElideStub = + T extends StubBase + ? (IsAny extends true ? T : [U] extends [Stubable] ? U : T) + : T; + // 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 @@ -178,14 +198,20 @@ export type RpcPromise = // Intersecting with `(Maybe)Provider` allows pipelining. // prettier-ignore type Result = - IsAny extends true ? UnknownResult - : IsUnknown extends true ? UnknownResult - : R extends Stubable ? RpcPromise + IsAny extends true ? RpcPromise + // `RpcPromise`: always safe to normalize — `Result` is idempotent — so a declared + // `RpcPromise` return produces the same type as a `Promise` return. + : R extends PromiseLike & StubBase ? Result + // Bare stubs: elide per `ElideStub` above. Note there is no `RpcCompatible` re-check + // here: anything matching our `StubBase` was produced by machinery that already enforced + // the constraint, and re-evaluating `RpcCompatible` in this arm recurses through its + // `Stub` member back into `Result`, tripping TS2615 circularity errors in mapped + // types. + : R extends StubBase ? RpcPromise> : R extends RpcCompatible ? RpcPromise : never; type IsAny = 0 extends (1 & T) ? true : false; -type UnknownResult = Promise & Provider & StubBase; // Type for method or property on an RPC interface. // For methods, unwrap `Stub`s in parameters, and rewrite returns to be `Result`s. @@ -193,7 +219,7 @@ type UnknownResult = Promise & Provider & StubBase; // For properties, rewrite types to be `Result`s. // In each case, unwrap `Promise`s. type MethodOrProperty = V extends (...args: infer P) => infer R - ? (...args: UnstubifyAll

) => IsAny extends true ? UnknownResult : Result> + ? (...args: UnstubifyAll

) => Result> : Result>; // Type for the callable part of an `Provider` if `T` is callable. @@ -246,6 +272,9 @@ type TupleProvider> = { // Base type for all other types providing RPC-like interfaces. // Rewrites all methods/properties to be `MethodOrProperty`s, while preserving callable types. +// `__RPC_TARGET_BRAND` deliberately flows through the key mapping (as a `never` property) so +// stubs of branded targets stay assignable to workers-types' `Stubable`. Such stubs match our +// `Stubable` too — harmless, since all machinery checks `StubBase` before `Stubable`. export type Provider = MaybeCallableProvider & (T extends ReadonlyArray ? number extends T["length"] ? ArrayProvider : TupleProvider