Skip to content
Draft
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
30 changes: 30 additions & 0 deletions .changeset/true-geese-bow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@solana/rpc-types': minor
---

Add `UnwrapRpcResponse<T>` 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<T>` is a conditional type:

```ts
type UnwrapRpcResponse<T> = T extends SolanaRpcResponse<infer U> ? 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<T>(notification: SolanaRpcResponse<T>): { slot: Slot; value: T };
splitSolanaRpcResponse(notification: undefined): { slot: undefined; value: undefined };
splitSolanaRpcResponse<T>(notification: SolanaRpcResponse<T> | undefined): { slot: Slot | undefined; value: T | undefined };
splitSolanaRpcResponse<T>(notification: T): { slot: undefined; value: T };
```

The third overload supports piping straight from store state (e.g. `splitSolanaRpcResponse(store.getUnifiedState().data)`) without an external null-check.

```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 }
```
79 changes: 79 additions & 0 deletions packages/rpc-types/src/__tests__/rpc-api-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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('returns `{ value: null, slot: undefined }` for `null`', () => {
expect(splitSolanaRpcResponse(null as unknown as undefined)).toStrictEqual({
slot: undefined,
value: null,
});
});

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<undefined>;
expect(splitSolanaRpcResponse(undefinedValueEnvelope)).toStrictEqual({
slot: 7n,
value: undefined,
});

const nullValueEnvelope = {
context: { slot: 8n as Slot },
value: null,
} as SolanaRpcResponse<null>;
expect(splitSolanaRpcResponse(nullValueEnvelope)).toStrictEqual({
slot: 8n,
value: null,
});
});
});
38 changes: 38 additions & 0 deletions packages/rpc-types/src/__typetests__/rpc-api-typetest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { type SolanaRpcResponse, splitSolanaRpcResponse, type UnwrapRpcResponse } from '../rpc-api';
import type { Slot } from '../typed-numbers';

// [DESCRIBE] UnwrapRpcResponse
{
// Unwraps `SolanaRpcResponse<U>` to `U`
null as unknown as UnwrapRpcResponse<SolanaRpcResponse<{ lamports: bigint }>> satisfies { lamports: bigint };

// Non-envelope types pass through unchanged
null as unknown as UnwrapRpcResponse<{ lamports: bigint }> satisfies { lamports: bigint };
null as unknown as UnwrapRpcResponse<number> satisfies number;
null as unknown as UnwrapRpcResponse<string> 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<T> | 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;
}
71 changes: 71 additions & 0 deletions packages/rpc-types/src/rpc-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,74 @@ export type SolanaRpcResponse<TValue> = Readonly<{
context: Readonly<{ slot: Slot }>;
value: TValue;
}>;

/**
* Unwraps `SolanaRpcResponse<U>` → `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<SolanaRpcResponse<{ lamports: bigint }>>;
* // ^? { lamports: bigint }
*
* type AccountValue = UnwrapRpcResponse<{ lamports: bigint }>;
* // ^? { lamports: bigint }
* ```
*/
export type UnwrapRpcResponse<T> = T extends SolanaRpcResponse<infer U> ? 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<T>(notification: SolanaRpcResponse<T>): { slot: Slot; value: T };
export function splitSolanaRpcResponse(notification: undefined): { slot: undefined; value: undefined };
export function splitSolanaRpcResponse<T>(notification: SolanaRpcResponse<T> | undefined): {
slot: Slot | undefined;
value: T | undefined;
};
export function splitSolanaRpcResponse<T>(notification: T): { slot: undefined; value: T };
export function splitSolanaRpcResponse<T>(notification: T | undefined): {
slot: Slot | undefined;
value: UnwrapRpcResponse<T> | undefined;
} {
if (
notification != null &&
typeof notification === 'object' &&
'context' in notification &&
'value' in notification
) {
const envelope = notification as SolanaRpcResponse<unknown>;
return { slot: envelope.context.slot, value: envelope.value as UnwrapRpcResponse<T> };
}
return { slot: undefined, value: notification as UnwrapRpcResponse<T> | undefined };
}
Loading