Skip to content

Commit b9d4e6b

Browse files
committed
Add UnwrapRpcResponse<T> + isSolanaRpcResponse() to @solana/rpc-types
Adds `UnwrapRpcResponse<T>` (a conditional type that unwraps `SolanaRpcResponse<U> → U` and passes non-envelope types through) and `isSolanaRpcResponse()` (a runtime type guard) to `@solana/rpc-types`. The guard validates the envelope shape by duck-typing `context.slot: bigint` and the presence of `value` — only the load-bearing fields, so adding new envelope fields like `apiVersion` in the future doesn't ripple through the guard's contract. The narrowed type is `SolanaRpcResponse<UnwrapRpcResponse<T>>`, inferring the inner type from `T` directly so callers don't need a second generic parameter. Callers that previously needed to lift `slot` from `context` can branch on the guard and access whatever fields they need.
1 parent c3ebc28 commit b9d4e6b

6 files changed

Lines changed: 249 additions & 1 deletion

File tree

.changeset/true-geese-bow.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@solana/rpc-types': minor
3+
---
4+
5+
Add `UnwrapRpcResponse<T>` type and `isSolanaRpcResponse()` runtime helper alongside `SolanaRpcResponse`. Use them to detect and unwrap notifications that may or may not be wrapped in a `SolanaRpcResponse` envelope.
6+
7+
`UnwrapRpcResponse<T>` is a conditional type:
8+
9+
```ts
10+
type UnwrapRpcResponse<T> = T extends SolanaRpcResponse<infer U> ? U : T;
11+
```
12+
13+
`isSolanaRpcResponse()` is a type guard that validates the envelope shape by checking `context.slot: bigint` and the presence of `value`, leaving room for additional envelope fields without changing the guard's contract. The narrowed type is `SolanaRpcResponse<UnwrapRpcResponse<T>>`, so callers don't need to spell out the inner type separately.
14+
15+
```ts
16+
import { isSolanaRpcResponse } from '@solana/rpc-types';
17+
18+
function lift<T>(notification: T) {
19+
if (isSolanaRpcResponse(notification)) {
20+
return { slot: notification.context.slot, value: notification.value };
21+
}
22+
return { slot: undefined, value: notification };
23+
}
24+
```

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ Four private "impl" packages (`@solana/crypto-impl`, `@solana/text-encoding-impl
4646
- **Framework**: Jest v30 with `@swc/jest` for TypeScript transformation.
4747
- **Shared config**: `packages/test-config/` (`@solana/test-config`).
4848
- **File naming**: `*-test.ts` (runs in both environments), `*-test.node.ts` (Node only), `*-test.browser.ts` (browser/jsdom only). Tests live in `src/__tests__/`.
49-
- **Type tests**: `src/__typetests__/` directories contain compile-time type tests using `satisfies` and `@ts-expect-error`.
49+
- **Type tests**: `src/__typetests__/` directories contain compile-time type tests using `satisfies` and `@ts-expect-error`. Structure each file as one or more `// [DESCRIBE] <name>` blocks, with each individual test wrapped in its own nested `{ ... }` block (preceded by a one-line `// comment` describing the test) so that variable scopes don't leak between tests.
5050
- **Lint/prettier**: Also run through Jest runners (`jest-runner-eslint`, `jest-runner-prettier`).
5151
- **Commands**: `pnpm test` runs all unit tests. `pnpm lint` runs lint checks. `pnpm style:fix` auto-fixes formatting.
5252
- **`expect.assertions`**: Only use `expect.assertions(n)` in **async** tests (where you need to guarantee the expected number of assertions ran). Synchronous tests do not need it.

packages/rpc-types/README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,22 @@ This type represents a number which has been encoded as a string for transit ove
3535

3636
This type represents a Unix timestamp in _seconds_. It is represented as a `bigint` in client code and an `i64` in server code.
3737

38+
### `UnwrapRpcResponse<T>`
39+
40+
A conditional type that 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.
41+
42+
```ts
43+
import type { SolanaRpcResponse, UnwrapRpcResponse } from '@solana/rpc-types';
44+
45+
type AccountValue = UnwrapRpcResponse<SolanaRpcResponse<{ lamports: bigint }>>;
46+
// ^? { lamports: bigint }
47+
48+
type AccountValue = UnwrapRpcResponse<{ lamports: bigint }>;
49+
// ^? { lamports: bigint }
50+
```
51+
52+
Pairs with [`isSolanaRpcResponse()`](#issolanarpcresponse) for runtime detection.
53+
3854
## Functions
3955

4056
### `assertIsLamports()`
@@ -140,6 +156,23 @@ import { lamports } from '@solana/rpc-types';
140156
await transfer(address(fromAddress), address(toAddress), lamports(100000n));
141157
```
142158

159+
### `isSolanaRpcResponse()`
160+
161+
Type-guards a notification as a `SolanaRpcResponse` envelope. Validates `context.slot: bigint` and the presence of `value`, so adding new fields to the envelope in the future doesn't change the guard's contract — only the load-bearing fields are checked. The narrowed type is `SolanaRpcResponse<UnwrapRpcResponse<T>>`, so callers don't need to spell out the inner type separately.
162+
163+
```ts
164+
import { isSolanaRpcResponse, type SolanaRpcResponse } from '@solana/rpc-types';
165+
166+
function lift<T>(notification: T) {
167+
if (isSolanaRpcResponse(notification)) {
168+
return { slot: notification.context.slot, value: notification.value };
169+
}
170+
return { slot: undefined, value: notification };
171+
}
172+
```
173+
174+
Pairs with [`UnwrapRpcResponse<T>`](#unwraprpcresponset) for the type-level counterpart.
175+
143176
### `stringifiedBigInt()`
144177

145178
This helper combines _asserting_ that a string represents a `bigint` with _coercing_ it to the `StringifiedBigInt` type. It's best used with untrusted input.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { isSolanaRpcResponse, type SolanaRpcResponse } from '../rpc-api';
2+
import type { Slot } from '../typed-numbers';
3+
4+
describe('isSolanaRpcResponse', () => {
5+
it('returns true for a well-formed envelope', () => {
6+
const envelope: SolanaRpcResponse<{ lamports: bigint }> = {
7+
context: { slot: 99n as Slot },
8+
value: { lamports: 5n },
9+
};
10+
expect(isSolanaRpcResponse(envelope)).toBe(true);
11+
});
12+
13+
it('accepts envelopes whose value is `undefined` or `null`', () => {
14+
expect(isSolanaRpcResponse({ context: { slot: 7n }, value: undefined })).toBe(true);
15+
expect(isSolanaRpcResponse({ context: { slot: 8n }, value: null })).toBe(true);
16+
});
17+
18+
it('ignores extra fields on `context` (future-proof against new envelope fields)', () => {
19+
expect(isSolanaRpcResponse({ context: { apiVersion: '2.0', slot: 1n }, value: 42 })).toBe(true);
20+
});
21+
22+
it('returns false for raw notifications without an envelope', () => {
23+
expect(isSolanaRpcResponse({ parent: 9n, root: 8n, slot: 10n })).toBe(false);
24+
});
25+
26+
it('returns false for primitives', () => {
27+
expect(isSolanaRpcResponse(42)).toBe(false);
28+
expect(isSolanaRpcResponse('hello')).toBe(false);
29+
expect(isSolanaRpcResponse(true)).toBe(false);
30+
});
31+
32+
it('returns false for nullish input', () => {
33+
expect(isSolanaRpcResponse(undefined)).toBe(false);
34+
expect(isSolanaRpcResponse(null)).toBe(false);
35+
});
36+
37+
it('returns false when `value` is missing', () => {
38+
expect(isSolanaRpcResponse({ context: { slot: 1n } })).toBe(false);
39+
});
40+
41+
it('returns false when `context` is missing', () => {
42+
expect(isSolanaRpcResponse({ value: 42 })).toBe(false);
43+
});
44+
45+
it('returns false when `context` is not an object', () => {
46+
expect(isSolanaRpcResponse({ context: 'oops', value: 42 })).toBe(false);
47+
expect(isSolanaRpcResponse({ context: null, value: 42 })).toBe(false);
48+
});
49+
50+
it('returns false when `context.slot` is missing', () => {
51+
expect(isSolanaRpcResponse({ context: { apiVersion: '2.0' }, value: 42 })).toBe(false);
52+
});
53+
54+
it('returns false when `context.slot` is not a bigint', () => {
55+
expect(isSolanaRpcResponse({ context: { slot: 1 }, value: 42 })).toBe(false);
56+
expect(isSolanaRpcResponse({ context: { slot: '1' }, value: 42 })).toBe(false);
57+
expect(isSolanaRpcResponse({ context: { slot: null }, value: 42 })).toBe(false);
58+
});
59+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { isSolanaRpcResponse, type SolanaRpcResponse, type UnwrapRpcResponse } from '../rpc-api';
2+
import type { Slot } from '../typed-numbers';
3+
4+
// [DESCRIBE] UnwrapRpcResponse
5+
{
6+
// Unwraps `SolanaRpcResponse<U>` to `U`
7+
{
8+
null as unknown as UnwrapRpcResponse<SolanaRpcResponse<{ lamports: bigint }>> satisfies {
9+
lamports: bigint;
10+
};
11+
}
12+
13+
// Non-envelope types pass through unchanged
14+
{
15+
null as unknown as UnwrapRpcResponse<{ lamports: bigint }> satisfies { lamports: bigint };
16+
null as unknown as UnwrapRpcResponse<number> satisfies number;
17+
null as unknown as UnwrapRpcResponse<string> satisfies string;
18+
}
19+
}
20+
21+
// [DESCRIBE] isSolanaRpcResponse
22+
{
23+
// Narrows an envelope-typed value to the same envelope shape.
24+
{
25+
const envelope = null as unknown as SolanaRpcResponse<{ lamports: bigint }>;
26+
if (isSolanaRpcResponse(envelope)) {
27+
envelope.context.slot satisfies Slot;
28+
envelope.value satisfies { lamports: bigint };
29+
}
30+
}
31+
32+
// For a raw notification, the true-branch narrows to `SolanaRpcResponse<RawValue>`.
33+
// (Reaching the true branch on a raw value is impossible at runtime — the guard
34+
// validates the envelope shape — so the imprecision is benign.)
35+
{
36+
const raw = null as unknown as { parent: bigint; slot: bigint };
37+
if (isSolanaRpcResponse(raw)) {
38+
raw.context.slot satisfies Slot;
39+
raw.value satisfies { parent: bigint; slot: bigint };
40+
}
41+
}
42+
43+
// For a union of envelope and raw, the true branch narrows to an envelope wrapping
44+
// either inner type.
45+
{
46+
const mixed = null as unknown as SolanaRpcResponse<{ lamports: bigint }> | { lamports: bigint };
47+
if (isSolanaRpcResponse(mixed)) {
48+
mixed.context.slot satisfies Slot;
49+
mixed.value satisfies { lamports: bigint };
50+
} else {
51+
mixed satisfies { lamports: bigint };
52+
}
53+
}
54+
55+
// `unknown` input narrows to `SolanaRpcResponse<unknown>` in the true branch.
56+
{
57+
const u = null as unknown;
58+
if (isSolanaRpcResponse(u)) {
59+
u.context.slot satisfies Slot;
60+
u.value satisfies unknown;
61+
}
62+
}
63+
64+
// Generic-T call-site: the narrowing must work when invoked from inside a generic function
65+
// with a parameter typed as `T | undefined`. This mirrors how `useSubscription` consumes
66+
// the guard against a `ReactiveStreamStore`'s current value.
67+
{
68+
function generic<T>(value: T | undefined): {
69+
slot: Slot | undefined;
70+
value: UnwrapRpcResponse<T> | undefined;
71+
} {
72+
if (isSolanaRpcResponse(value)) {
73+
return { slot: value.context.slot, value: value.value };
74+
}
75+
return { slot: undefined, value: value as UnwrapRpcResponse<T> | undefined };
76+
}
77+
void generic;
78+
}
79+
}

packages/rpc-types/src/rpc-api.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,56 @@ export type SolanaRpcResponse<TValue> = Readonly<{
44
context: Readonly<{ slot: Slot }>;
55
value: TValue;
66
}>;
7+
8+
/**
9+
* Unwraps `SolanaRpcResponse<U>` → `U` at the type level so callers can surface
10+
* the inner value without losing static type information. Values that are not
11+
* wrapped in a `SolanaRpcResponse` envelope pass through unchanged.
12+
*
13+
* Pairs with {@link isSolanaRpcResponse} for runtime detection.
14+
*
15+
* @typeParam T - The raw notification shape.
16+
*
17+
* @example
18+
* ```ts
19+
* type AccountValue = UnwrapRpcResponse<SolanaRpcResponse<{ lamports: bigint }>>;
20+
* // ^? { lamports: bigint }
21+
*
22+
* type AccountValue = UnwrapRpcResponse<{ lamports: bigint }>;
23+
* // ^? { lamports: bigint }
24+
* ```
25+
*/
26+
export type UnwrapRpcResponse<T> = T extends SolanaRpcResponse<infer U> ? U : T;
27+
28+
/**
29+
* Type-guards a notification as a {@link SolanaRpcResponse} envelope. Validates the shape by
30+
* duck-typing for `context.slot: bigint` and the presence of `value`.
31+
*
32+
* The narrowed type is `SolanaRpcResponse<UnwrapRpcResponse<T>>`. In the false branch,
33+
* `notification` retains its original type.
34+
*
35+
* @typeParam T - The notification shape, which may be a raw value, an envelope, or a union of the two.
36+
* @param notification - The value to test.
37+
* @return `true` when `notification` is a `SolanaRpcResponse` envelope, narrowing accordingly.
38+
*
39+
* @example
40+
* ```ts
41+
* if (isSolanaRpcResponse(notification)) {
42+
* return { slot: notification.context.slot, value: notification.value };
43+
* }
44+
* ```
45+
*/
46+
export function isSolanaRpcResponse<T>(
47+
notification: SolanaRpcResponse<UnwrapRpcResponse<T>> | T,
48+
): notification is SolanaRpcResponse<UnwrapRpcResponse<T>> {
49+
return (
50+
notification != null &&
51+
typeof notification === 'object' &&
52+
'context' in notification &&
53+
notification.context != null &&
54+
typeof notification.context === 'object' &&
55+
'slot' in notification.context &&
56+
typeof notification.context.slot === 'bigint' &&
57+
'value' in notification
58+
);
59+
}

0 commit comments

Comments
 (0)