Typed client
wRPC has no TypeScript at runtime. The typed client is types only: you declare the api once as an ordinary interface and thread it through connect<Api>(). Nothing is generated, nothing is checked at runtime — what you buy is autocompletion and a compile error on a typo.
import { connect, type SubscriptionContract } from '@alexify/wrpc';
interface Api {
chat: {
send(args: { text: string }): Promise<{ id: string }>;
onMessage: SubscriptionContract<{ room: string }, { text: string }>;
};
'auth.v1': { signIn(args: { login: string }): Promise<{ token: string }> };
}
const client = await connect<Api>('wss://host/api');
await client.load('chat');
const { id } = await client.api.chat.send({ text: 'hi' });connect is a one-line alias of WrpcClient.connect, added because a function is where a type argument reads naturally. Both accept the type argument; use whichever you prefer.
Writing a contract
A unit is an object of members. A call is a method taking one args object and returning a promise; a subscription is declared with SubscriptionContract<Args, Data>:
interface Api {
math: {
double(args: { n: number }): Promise<number>;
ping(): Promise<void>; // no arguments
};
feed: {
ticks: SubscriptionContract<{ room: string }, { n: number }>;
};
'auth.v1': { signIn(args: { login: string }): Promise<{ token: string }> };
}Versioned units are just keys with a dot, exactly as they are on the wire and in load().
What the mapping gives you:
- A call keeps its declared arguments and gains the trailing
CallOptions— which is what carries{ signal }. - Its result is awaited whether the contract promised one or not.
- A
SubscriptionContractmember becomes{ subscribe, iterate }rather than something callable, so calling a subscription is a compile error. load()only accepts unit keys the contract declares.
const value = await client.api.math.double({ n: 2 }); // number
const sub = client.api.feed.ticks.subscribe({ room: 'a' }, { onData }); // Subscription
for await (const tick of client.api.feed.ticks.iterate({ room: 'a' })) {} // { n: number }Three sharp edges
A zero-argument member keeps its args slot. Slot 0 on the wire is always the procedure's arguments, so collapsing the tuple would compile a call that ships {"signal":{}} as the args and silently drops the cancellation:
await client.api.math.ping(undefined, { signal }); // ✅
await client.api.math.ping({ signal }); // ❌ compile errorA member with two parameters is rejected. A wRPC procedure receives exactly one args object, so anything else maps to InvalidContractMember, whose text the compiler quotes back at the call site:
interface Bad { unit: { method(a: string, b: number): Promise<void> } }
// ^ 'wrpc: a contract member is `(args) => Promise<T>` or a SubscriptionContract'A contract key named on is not mapped. A unit is an Emitter at runtime, so api.chat.on has to stay the listener registration:
client.api.chat.on('message', (data) => {}); // always the event listenerTyped events
Two reserved contract keys type the realtime surface — declarations only, zero runtime bytes:
interface Api {
chat: {
send(args: { text: string }): Promise<{ id: string }>;
// server -> client: what `api.chat.on(...)` delivers, and what the
// server's ask (`client.respond`) carries. The function form encodes
// the ask's answer type; a bare payload type fits fire-and-forget.
events: {
message: (data: { text: string; from: string }) => void;
confirm: (data: { id: string }) => boolean;
};
// client -> server: what `client.sendEvent('chat/typing', ...)` carries
// (the router's inbound `on` handlers receive it).
sends: { typing: { on: boolean } };
};
}With events declared, the unit emitter narrows: api.chat.on('message', (data) => ...) types data, and an undeclared name is a compile error. client.respond('chat/confirm', handler) types the payload and the answer; client.sendEvent('chat/typing', data) types the payload. Undeclared names on sendEvent/respond stay allowed (they degrade to unknown), so partial contracts keep working.
The server side declares both halves in the router — inbound handlers under the reserved on key as always, outbound payloads under the declaration-only emits key (signature-language descriptors) — and both travel through introspection, so wrpc types generates these blocks for you.
Without a contract
connect(url) with no type argument behaves exactly as it always did: client.api is a loose record and nothing tightens under you. That is what the IsAny guard in the types is for, and tests/index.test-d.ts asserts it.
Utility types
Exported for building your own helpers on top:
| Type | What it gives |
|---|---|
TypedApi<Api> | The whole mapped api — what client.api is. |
TypedUnit<Unit> / TypedMethod<T> | One unit / one member, mapped. |
TypedSubscriptionMethod<Args, Data> | What a SubscriptionContract becomes. |
InferArgs<T> / InferResult<T> | A member's argument and result types. |
FirstArg<Params> | The first element of a parameter tuple, void when empty. |
ContractArgs / TypedParams | The parameter-tuple plumbing behind the mapping. |
UntypedApi / IsAny<T> / InvalidContractMember | The escape hatch, the any guard, the error message. |
UnitEvents<Unit> / TypedUnitEmitter<Events> | The declared events map and the narrowed unit emitter. |
ServerEventName/Data/Answer<Api, …> / ClientSendName/Data<Api, …> | The unit/event name and payload plumbing behind typed respond/sendEvent. |
Generating the contract
Writing the interface by hand gives you exact types. When you would rather not write it at all, generate it from a running server — the CLI emits the same shape of interface:
npx wrpc types http://localhost:8000/api --out api.d.tsSee Codegen CLI. The two halves meet in the middle: generate, then hand-edit if you want something more precise than the server's signature descriptors can express.
Static introspection
load() asks the running server what a unit looks like — one system/introspect round-trip, repeated on every reconnect. When the contract is generated anyway, the runtime shape can be generated too: the same CLI run emits the raw introspection as an importable module, and client.use() scaffolds from it with no wire traffic at all. All three ways to type and build client.api start from the same description:
Solid arrows build something at runtime; dotted ones exist only for the type checker. One CLI run writes both artifacts:
wrpc types http://localhost:8000/api --out api.d.ts --schema api.static.jsimport { connect } from '@alexify/wrpc';
import type { Api } from './api.js';
import schema from './api.static.js';
const client = await connect<Api>('wss://example.com/api');
client.use(schema); // synchronous — client.api is complete right hereThe rules, in the order they matter:
use()is synchronous and offline. It works beforeopen()and without a reachable server — the artifact is the contract, exactly as the generated.d.tsis.- Dynamic wins.
load()ing a unit supersedes its static scaffold and the unit reloads on reconnect from then on;use()on an already-load()ed unit is a no-op. The two coexist: static for the stable core,load()for anything you want introspected live. - Static units never re-introspect. A reconnect re-loads
load()ed units only, and the'reconnect'event'sunitspayload lists only those. - Pre-validation compiles from the artifact's schemas (when you inject a client-side ajv) and does not update until you regenerate — contract drift is the codegen's responsibility, the same trade a generated
.d.tsmakes. - A server can keep schemas out of the artifact with
introspection: { schemas: false }, which shrinks it to names and access.
Server-side types
The router side is typed too — procedure(), defineRouter(), Context, Client, Session and the packet shapes all come from the hand-maintained index.d.ts. A handler's context and args are contextually typed, and args is deliberately loose: what actually constrains it is the input validator, which is a runtime thing.
import { defineRouter, procedure, type Context } from '@alexify/wrpc';
const router = defineRouter({
chat: {
send: procedure({
input: (args: unknown) => schema.parse(args),
handler: async (context: Context, args: { text: string }) => ({ id: '1' }),
}),
},
});