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
6 changes: 6 additions & 0 deletions .changeset/result-stub-elision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"capnweb": minor
"capnweb-validate": minor
---

Fixed methods declared to return `Promise<RpcStub<T>>` producing broken stub-of-stub result types; they now type the same as `Promise<T>`. If you annotated such a result as `RpcPromise<RpcStub<T>>`, write `RpcPromise<T>` instead.
92 changes: 91 additions & 1 deletion __type-tests__/capnweb-validate.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -33,9 +33,99 @@ api.getPair().then((pair) => {
void mutablePair
})

// Stub elision mirrors the main package: a `Promise<ValidatedStub<T>>` return for a branded
// target produces the same type as a `Promise<T>` 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<Counter>
viaStub(): Promise<ValidatedStub<Counter>>
viaFn(): Promise<Formatter>
viaFnStub(): Promise<ValidatedStub<Formatter>>
getPlain(): Promise<ValidatedStub<Api>>
getAnyStub(): Promise<ValidatedStub<any>>
getAny(): Promise<any>
getUnknown(): Promise<unknown>
maybeStub(): Promise<ValidatedStub<Counter> | null>
consumeMaybe(counter: ValidatedStub<Counter> | null): Promise<number>
dies(): Promise<never>
}

// 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<ValidatedStub<Counter> extends { readonly __RPC_TARGET_BRAND: never } ? true : false, false>
>

let stubApi = validateStub<StubReturningApi>(rawStub)

const viaTarget = stubApi.viaTarget()
const viaStub = stubApi.viaStub()
type _ValidatedStubElides = Expect<Equal<typeof viaStub, typeof viaTarget>>
expectAssignable<Promise<number>>(viaStub.increment(2))

// Callable stubs elide too.
const fnViaTarget = stubApi.viaFn()
const fnViaStub = stubApi.viaFnStub()
type _ValidatedCallableStubElides = Expect<Equal<typeof fnViaStub, typeof fnViaTarget>>

// A `never`-returning method stays `never` instead of matching the promise-normalization arm
// with `U = unknown`.
const neverResult = stubApi.dies()
type _NeverStaysNever = Expect<Equal<typeof neverResult, never>>

// Elision distributes over unions, so a `ValidatedStub<T> | 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<ChainApi>(rawStub)
const chained = chainApi.chain()
type _RpcPromiseNormalizes = Expect<Equal<typeof chained, typeof viaTarget>>

const plainStubPromise = stubApi.getPlain()

async function assertValidatedStubShapes() {
const awaitedCounter = await viaStub
expectAssignable<Promise<number>>(awaitedCounter.increment(1))

// Plain-interface stubs keep the wrapper: awaiting still yields the stub itself.
const inner: ValidatedStub<Api> = await plainStubPromise
expectAssignable<Promise<number>>(inner.getCounter().increment(1))

// Union elision: awaiting yields the payload stub or null.
const maybeCounter = await maybe
if (maybeCounter !== null) {
expectAssignable<Promise<number>>(maybeCounter.increment(1))
} else {
expectType<null>(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<unknown> & StubBase<unknown>`.
const anyStubResult = stubApi.getAnyStub()
const anyResult = stubApi.getAny()
expectAssignable<Disposable>(anyStubResult)
expectAssignable<Disposable>(anyResult)
expectAssignable<Disposable>(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")
131 changes: 131 additions & 0 deletions __type-tests__/stub-elision.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// 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.
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<string>
}

interface ElisionApi {
viaTarget(): Promise<Counter>
viaStub(): Promise<RpcStub<Counter>>
counterProp: RpcStub<Counter>
viaFn(): Promise<Formatter>
viaFnStub(): Promise<RpcStub<Formatter>>
wrapped(): Promise<{ s: RpcStub<Counter> }>
listStubs(): Promise<RpcStub<Counter>[]>
consumeCounter(counter: RpcStub<Counter>): Promise<number>
getApi(): Promise<RpcStub<PlainApi>>
getAnyStub(): Promise<RpcStub<any>>
anyStubProp: RpcStub<any>
maybeStub(): Promise<RpcStub<Counter> | null>
consumeMaybe(counter: RpcStub<Counter> | null): Promise<number>
}

// 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<Equal<RpcStub<Counter> extends Stubable ? true : false, true>>
type _CallableStubIsStillStubable = Expect<Equal<RpcStub<Formatter> extends Stubable ? true : false, true>>

declare const api: RpcStub<ElisionApi>

// 1. A `Promise<RpcStub<T>>` return is indistinguishable from a `Promise<T>` return.
const viaTarget = api.viaTarget()
const viaStub = api.viaStub()
type _StubReturnMatchesTargetReturn = Expect<Equal<typeof viaStub, typeof viaTarget>>
expectType<RpcPromise<Counter>>(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<Equal<Awaited<typeof viaStub>, RpcStub<Counter>>>

// Pipelining on the elided promise works like any other RpcPromise<Counter>.
expectAssignable<Promise<number>>(viaStub.increment(3))
expectAssignable<Promise<number>>(viaStub.value)
viaStub.onRpcBroken((_error) => {})

// 5. An interface property typed `RpcStub<T>` elides identically.
const propPromise = api.counterProp
type _PropertyElides = Expect<Equal<typeof propPromise, typeof viaTarget>>

// 6. Callable stubs (`RpcStub<(x: number) => string>`) elide too — the second Stubable path.
const fnViaTarget = api.viaFn()
const fnViaStub = api.viaFnStub()
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.
const maybeViaMethod = api.maybeStub()
api.consumeMaybe(maybeViaMethod)

// 8. map() over a declared `RpcStub<T>[]` return: the callback placeholder is `T`-shaped,
// so pipelined calls on elements typecheck.
const mapped = api.listStubs().map((c) => c.increment(2))
expectAssignable<Promise<number[]>>(mapped)

// 9. Self-referential stub returns compile (recursion in `Result` terminates).
declare class Node extends RpcTarget {
next(): Promise<RpcStub<Node>>
}
declare const nodeStub: RpcStub<Node>
const nextNode = nodeStub.next()
expectType<RpcPromise<Node>>(nextNode)
const grandchild = nextNode.next()
expectType<RpcPromise<Node>>(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<RpcStub<Counter>>(counter)

const wrapped = await api.wrapped()
expectType<RpcStub<Counter>>(wrapped.s)
expectAssignable<Promise<number>>(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<PlainApi> = await api.getApi()
expectAssignable<Promise<number>>(s.ping())
s.dup()

// `RpcStub<any>` results are not elided either: `[any] extends [Stubable]` is true, so
// without the IsAny guard these would collapse to `RpcPromise<unknown>` and await to
// `unknown`, losing the stub surface.
const anyFromMethod = await api.getAnyStub()
const anyFromProp = await api.anyStubProp
expectAssignable<Disposable>(anyFromMethod)
expectAssignable<Disposable>(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()
49 changes: 39 additions & 10 deletions packages/capnweb-validate/src/internal/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,17 @@ type BaseType =
| Response
| Headers;

type Stubify<T> = T extends Stubable
? ValidatedStub<T>
: T extends Promise<infer U>
? Stubify<U>
: T extends StubBase<unknown>
? 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> = T extends Promise<infer U>
? Stubify<U>
: T extends StubBase<unknown>
? T
: T extends Stubable
? ValidatedStub<T>
: T extends Map<infer K, infer V>
? Map<Stubify<K>, Stubify<V>>
: T extends Set<infer V>
Expand Down Expand Up @@ -157,10 +162,29 @@ type Unstubify<T> =
type UnstubifyAll<T extends readonly unknown[]> = {
[K in keyof T]: Unstubify<T[K]>;
};
type StubResult<T> = Promise<Stubify<T>> & ValidatedStub<T> & StubBase<T>;
type IsAny<T> = 0 extends 1 & T ? true : false;
type StubResultInner<T> = Promise<Stubify<T>> & ValidatedStub<T> & StubBase<T>;
// 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<T> | 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<any>` or `ValidatedStub<any>` return would recurse through the eliding arms and
// collapse to `Promise<unknown> & StubBase<unknown>` instead of keeping the full stub surface.
type StubResult<T> =
IsAny<T> extends true ? StubResultInner<T>
: T extends PromiseLike<unknown> & StubBase<infer U> ? StubResult<U>
: T extends StubBase<infer U>
? (IsAny<U> extends true ? StubResultInner<T>
: [U] extends [Stubable] ? StubResult<U> : StubResultInner<T>)
: StubResultInner<T>;
type StubMethodOrProperty<T> = T extends (...args: infer P) => infer R
? (...args: UnstubifyAll<P>) => StubResult<Awaited<R>>
: StubResult<Awaited<T>>;
// 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> = T extends (...args: infer P) => infer R
? (...args: UnstubifyAll<P>) => StubResult<Awaited<R>>
: unknown;
Expand Down Expand Up @@ -196,9 +220,14 @@ type MapCallbackReturn<V> =
export type ValidatedStub<T> = MaybeCallableStub<T> &
(T extends object
? {
[K in Exclude<keyof T, symbol | keyof StubBase<never>>]: StubMethodOrProperty<
T[K]
>;
[K in Exclude<
keyof T,
| symbol
| "__RPC_TARGET_BRAND"
| "__WORKER_ENTRYPOINT_BRAND"
| "__DURABLE_OBJECT_BRAND"
| keyof StubBase<never>
>]: StubMethodOrProperty<T[K]>;
} & {
map<V>(callback: (value: MapCallbackValue<NonNullable<T>>) => MapCallbackReturn<V>): StubResult<
Array<V>
Expand Down
Loading
Loading