From 8dcf3ddce03819f4c16bba3e79e3b964fef002c8 Mon Sep 17 00:00:00 2001 From: Callum Date: Tue, 19 May 2026 09:16:54 +0000 Subject: [PATCH] Add `UnwrapRpcResponse` + `splitSolanaRpcResponse()` to `@solana/rpc-types` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a co-located type-level and runtime decomposition for the `SolanaRpcResponse` envelope, so callers can handle notifications that may or may not be wrapped without re-implementing the duck-type check. `UnwrapRpcResponse` is a conditional type that unwraps `SolanaRpcResponse` → `U` and passes through other shapes unchanged. `splitSolanaRpcResponse()` runtime-detects the envelope via duck-type (`'context' in x && 'value' in x`) and decomposes into the inner value and the lifted slot. Raw notifications pass through with `slot: undefined`; `undefined` input returns both halves as `undefined`. Four overloads narrow the return type to match the input: `SolanaRpcResponse` → `{ slot: Slot; value: T }`; `undefined` → `{ slot: undefined; value: undefined }`; `SolanaRpcResponse | undefined` → `{ slot: Slot | undefined; value: T | undefined }`; raw `T` → `{ slot: undefined; value: T }`. The third overload lets callers pipe a possibly-undefined source straight through without an external null-check. T is intentionally unconstrained — the runtime check handles arbitrary shapes, and the overloads surface precise return types per call site. Tests cover the envelope path, raw passthrough, `undefined`/`null` inputs, malformed shapes (missing `context` or `value`), primitives, and envelopes whose `value` itself is `undefined` / `null`. --- .changeset/true-geese-bow.md | 29 +++++++ packages/rpc-types/README.md | 37 +++++++++ .../rpc-types/src/__tests__/rpc-api-test.ts | 72 ++++++++++++++++++ .../src/__typetests__/rpc-api-typetest.ts | 46 ++++++++++++ packages/rpc-types/src/rpc-api.ts | 75 +++++++++++++++++++ 5 files changed, 259 insertions(+) create mode 100644 .changeset/true-geese-bow.md create mode 100644 packages/rpc-types/src/__tests__/rpc-api-test.ts create mode 100644 packages/rpc-types/src/__typetests__/rpc-api-typetest.ts diff --git a/.changeset/true-geese-bow.md b/.changeset/true-geese-bow.md new file mode 100644 index 000000000..9ef5601bb --- /dev/null +++ b/.changeset/true-geese-bow.md @@ -0,0 +1,29 @@ +--- +'@solana/rpc-types': minor +--- + +Add `UnwrapRpcResponse` type and `splitSolanaRpcResponse()` runtime helper alongside `SolanaRpcResponse`. Use them to decompose notifications that may or may not be wrapped in a `SolanaRpcResponse` envelope into their inner value and slot. + +`UnwrapRpcResponse` is a conditional type: + +```ts +type UnwrapRpcResponse = T extends SolanaRpcResponse ? U : T; +``` + +`splitSolanaRpcResponse()` runtime-detects the envelope shape (`'context' in x && 'value' in x`) and decomposes accordingly. Raw notifications without the envelope pass through with `slot: undefined`; `undefined` input returns both halves as `undefined`. Overloads narrow the return type to match the input: + +```ts +splitSolanaRpcResponse(notification: SolanaRpcResponse): { slot: Slot; value: T }; +splitSolanaRpcResponse(notification: T extends SolanaRpcResponse ? never : T): { slot: undefined; value: T }; +splitSolanaRpcResponse(notification: T): { slot: Slot | undefined; value: UnwrapRpcResponse }; +``` + +The third overload covers everything else — unions of envelope and raw shapes, `T | undefined` piped straight from store state (e.g. `splitSolanaRpcResponse(store.getUnifiedState().data)`). + +```ts +splitSolanaRpcResponse({ context: { slot: 99n }, value: { lamports: 5n } }); +// → { value: { lamports: 5n }, slot: 99n } + +splitSolanaRpcResponse({ slot: 10n, parent: 9n, root: 8n }); +// → { value: { slot: 10n, parent: 9n, root: 8n }, slot: undefined } +``` diff --git a/packages/rpc-types/README.md b/packages/rpc-types/README.md index 8161b32f4..1c335cf5c 100644 --- a/packages/rpc-types/README.md +++ b/packages/rpc-types/README.md @@ -35,6 +35,22 @@ This type represents a number which has been encoded as a string for transit ove This type represents a Unix timestamp in _seconds_. It is represented as a `bigint` in client code and an `i64` in server code. +### `UnwrapRpcResponse` + +A conditional type that unwraps `SolanaRpcResponse` → `U` at the type level so callers can surface the inner value without losing static type information. Values that are not wrapped in a `SolanaRpcResponse` envelope pass through unchanged. + +```ts +import type { SolanaRpcResponse, UnwrapRpcResponse } from '@solana/rpc-types'; + +type AccountValue = UnwrapRpcResponse>; +// ^? { lamports: bigint } + +type AccountValue = UnwrapRpcResponse<{ lamports: bigint }>; +// ^? { lamports: bigint } +``` + +Pairs with [`splitSolanaRpcResponse()`](#splitsolanarpcresponse) for runtime detection. + ## Functions ### `assertIsLamports()` @@ -140,6 +156,27 @@ import { lamports } from '@solana/rpc-types'; await transfer(address(fromAddress), address(toAddress), lamports(100000n)); ``` +### `splitSolanaRpcResponse()` + +Decomposes a response that may or may not be wrapped in a `SolanaRpcResponse` envelope into its `value` and `slot` halves. Runtime-detects the envelope shape via duck-type (`'context' in x && 'value' in x`); raw responses without the envelope pass through with `slot: undefined`. + +Accepts `T | undefined` so callers can pipe a possibly-`undefined` source straight through without an external null-check. Overloads narrow the return type to match the input. + +```ts +import { splitSolanaRpcResponse } from '@solana/rpc-types'; + +splitSolanaRpcResponse({ context: { slot: 99n }, value: { lamports: 5n } }); +// → { value: { lamports: 5n }, slot: 99n } + +splitSolanaRpcResponse({ slot: 10n, parent: 9n, root: 8n }); +// → { value: { slot: 10n, parent: 9n, root: 8n }, slot: undefined } + +splitSolanaRpcResponse(undefined); +// → { value: undefined, slot: undefined } +``` + +Pairs with [`UnwrapRpcResponse`](#unwraprpcresponset) for the type-level counterpart. + ### `stringifiedBigInt()` This helper combines _asserting_ that a string represents a `bigint` with _coercing_ it to the `StringifiedBigInt` type. It's best used with untrusted input. diff --git a/packages/rpc-types/src/__tests__/rpc-api-test.ts b/packages/rpc-types/src/__tests__/rpc-api-test.ts new file mode 100644 index 000000000..accfa6c1c --- /dev/null +++ b/packages/rpc-types/src/__tests__/rpc-api-test.ts @@ -0,0 +1,72 @@ +import { type SolanaRpcResponse, splitSolanaRpcResponse } from '../rpc-api'; +import type { Slot } from '../typed-numbers'; + +describe('splitSolanaRpcResponse', () => { + it('decomposes a SolanaRpcResponse envelope into its value and slot', () => { + const envelope: SolanaRpcResponse<{ lamports: bigint }> = { + context: { slot: 99n as Slot }, + value: { lamports: 5n }, + }; + expect(splitSolanaRpcResponse(envelope)).toStrictEqual({ + slot: 99n, + value: { lamports: 5n }, + }); + }); + + it('passes raw values through with `slot: undefined`', () => { + const raw = { parent: 9n, root: 8n, slot: 10n }; + expect(splitSolanaRpcResponse(raw)).toStrictEqual({ + slot: undefined, + value: raw, + }); + }); + + it('returns `{ value: undefined, slot: undefined }` for `undefined`', () => { + expect(splitSolanaRpcResponse(undefined)).toStrictEqual({ + slot: undefined, + value: undefined, + }); + }); + + it('treats objects missing `context` as raw values', () => { + const malformed = { value: 42 } as unknown; + expect(splitSolanaRpcResponse(malformed)).toStrictEqual({ + slot: undefined, + value: malformed, + }); + }); + + it('treats objects missing `value` as raw values', () => { + const malformed = { context: { slot: 1n } } as unknown; + expect(splitSolanaRpcResponse(malformed)).toStrictEqual({ + slot: undefined, + value: malformed, + }); + }); + + it('passes through primitive notifications unchanged', () => { + expect(splitSolanaRpcResponse(42)).toStrictEqual({ slot: undefined, value: 42 }); + expect(splitSolanaRpcResponse('hello')).toStrictEqual({ slot: undefined, value: 'hello' }); + expect(splitSolanaRpcResponse(true)).toStrictEqual({ slot: undefined, value: true }); + }); + + it('unwraps even when the envelope value is itself undefined or null', () => { + const undefinedValueEnvelope = { + context: { slot: 7n as Slot }, + value: undefined, + } as SolanaRpcResponse; + expect(splitSolanaRpcResponse(undefinedValueEnvelope)).toStrictEqual({ + slot: 7n, + value: undefined, + }); + + const nullValueEnvelope = { + context: { slot: 8n as Slot }, + value: null, + } as SolanaRpcResponse; + expect(splitSolanaRpcResponse(nullValueEnvelope)).toStrictEqual({ + slot: 8n, + value: null, + }); + }); +}); diff --git a/packages/rpc-types/src/__typetests__/rpc-api-typetest.ts b/packages/rpc-types/src/__typetests__/rpc-api-typetest.ts new file mode 100644 index 000000000..2aaf81a35 --- /dev/null +++ b/packages/rpc-types/src/__typetests__/rpc-api-typetest.ts @@ -0,0 +1,46 @@ +import { type SolanaRpcResponse, splitSolanaRpcResponse, type UnwrapRpcResponse } from '../rpc-api'; +import type { Slot } from '../typed-numbers'; + +// [DESCRIBE] UnwrapRpcResponse +{ + // Unwraps `SolanaRpcResponse` to `U` + null as unknown as UnwrapRpcResponse> satisfies { lamports: bigint }; + + // Non-envelope types pass through unchanged + null as unknown as UnwrapRpcResponse<{ lamports: bigint }> satisfies { lamports: bigint }; + null as unknown as UnwrapRpcResponse satisfies number; + null as unknown as UnwrapRpcResponse satisfies string; +} + +// [DESCRIBE] splitSolanaRpcResponse +{ + // Envelope: `value` is the unwrapped inner type, `slot` is `Slot` + const envelopeResult = splitSolanaRpcResponse(null as unknown as SolanaRpcResponse<{ lamports: bigint }>); + envelopeResult.value satisfies { lamports: bigint }; + envelopeResult.slot satisfies Slot; + + // Raw notification: `value` is the original type, `slot` is `undefined` + const rawResult = splitSolanaRpcResponse(null as unknown as { lamports: bigint }); + rawResult.value satisfies { lamports: bigint }; + rawResult.slot satisfies undefined; + + // `undefined` input: both halves are `undefined` + const undefinedResult = splitSolanaRpcResponse(undefined); + undefinedResult.value satisfies undefined; + undefinedResult.slot satisfies undefined; + + // `SolanaRpcResponse | undefined` (e.g. piped from store state): both halves widen + const maybeEnvelopeResult = splitSolanaRpcResponse( + null as unknown as SolanaRpcResponse<{ lamports: bigint }> | undefined, + ); + maybeEnvelopeResult.value satisfies { lamports: bigint } | undefined; + maybeEnvelopeResult.slot satisfies Slot | undefined; + + // `SolanaRpcResponse | T` (call site doesn't know which it has): `value` is `T`, + // `slot` widens to `Slot | undefined` since the envelope arm produces a real slot at runtime. + const mixedResult = splitSolanaRpcResponse( + null as unknown as SolanaRpcResponse<{ lamports: bigint }> | { lamports: bigint }, + ); + mixedResult.value satisfies { lamports: bigint }; + mixedResult.slot satisfies Slot | undefined; +} diff --git a/packages/rpc-types/src/rpc-api.ts b/packages/rpc-types/src/rpc-api.ts index 1a5b87097..49c8f95d1 100644 --- a/packages/rpc-types/src/rpc-api.ts +++ b/packages/rpc-types/src/rpc-api.ts @@ -4,3 +4,78 @@ export type SolanaRpcResponse = Readonly<{ context: Readonly<{ slot: Slot }>; value: TValue; }>; + +/** + * Unwraps `SolanaRpcResponse` → `U` at the type level so callers can surface + * the inner value without losing static type information. Values that are not + * wrapped in a `SolanaRpcResponse` envelope pass through unchanged. + * + * Pairs with {@link splitSolanaRpcResponse} for runtime detection. + * + * @typeParam T - The raw notification shape. + * + * @example + * ```ts + * type AccountValue = UnwrapRpcResponse>; + * // ^? { lamports: bigint } + * + * type AccountValue = UnwrapRpcResponse<{ lamports: bigint }>; + * // ^? { lamports: bigint } + * ``` + */ +export type UnwrapRpcResponse = T extends SolanaRpcResponse ? U : T; + +/** + * Decomposes a notification that may or may not be wrapped in a {@link SolanaRpcResponse} + * envelope into its `value` and `slot` halves. Runtime-detects the envelope shape via duck-type + * (`'context' in x && 'value' in x`); raw notifications without the envelope pass through with + * `slot: undefined`. + * + * Accepts `T | undefined` so callers can pipe straight from `store.getUnifiedState().data` + * without an external null-check. + * + * @typeParam T - The raw notification shape. + * @param notification - The value to decompose, or `undefined` while the store hasn't yet + * produced one. + * @return `{ value, slot }` where `value` is the unwrapped inner value (or the original + * notification when it doesn't match the envelope shape) and `slot` is lifted from + * `context.slot` (or `undefined` for raw notifications). + * + * @example + * ```ts + * splitSolanaRpcResponse({ context: { slot: 99n }, value: { lamports: 5n } }); + * // → { value: { lamports: 5n }, slot: 99n } + * + * splitSolanaRpcResponse({ slot: 10n, parent: 9n, root: 8n }); + * // → { value: { slot: 10n, parent: 9n, root: 8n }, slot: undefined } + * + * splitSolanaRpcResponse(undefined); + * // → { value: undefined, slot: undefined } + * ``` + */ +export function splitSolanaRpcResponse(notification: SolanaRpcResponse): { slot: Slot; value: T }; +export function splitSolanaRpcResponse(notification: T extends SolanaRpcResponse ? never : T): { + slot: undefined; + value: T; +}; +// Generic case: a `T` that may be an envelope, a raw notification, a union of the two, or +// `T | undefined` (e.g. piped straight from `store.getUnifiedState().data`). +export function splitSolanaRpcResponse(notification: T): { + slot: Slot | undefined; + value: UnwrapRpcResponse; +}; +export function splitSolanaRpcResponse(notification: T | undefined): { + slot: Slot | undefined; + value: UnwrapRpcResponse | undefined; +} { + if ( + notification != null && + typeof notification === 'object' && + 'context' in notification && + 'value' in notification + ) { + const envelope = notification as SolanaRpcResponse; + return { slot: envelope.context.slot, value: envelope.value as UnwrapRpcResponse }; + } + return { slot: undefined, value: notification as UnwrapRpcResponse | undefined }; +}