TypeScript Clients
Ultimo automatically generates type-safe TypeScript clients from your Rust RPC procedures, enabling seamless full-stack development.
query/mutationrequire theclient-genfeature. Every example on this page uses them — addfeatures = ["client-gen"]to yourultimodependency, or.query()/.mutation()won't resolve.
Quick Start
1. Define RPC in Rust
// Cargo.toml: ultimo = { version = "...", features = ["client-gen"] }
use ultimo::prelude::*;
use ultimo::rpc::{RpcRegistry, TS};
#[derive(Debug, Deserialize, Serialize, TS)]
struct GetUserInput {
id: u32,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
struct User {
id: u32,
name: String,
email: String,
}
let rpc = RpcRegistry::new();
// TS input/output types are derived automatically from GetUserInput/User —
// no manual TypeScript strings needed.
rpc.query("getUser", |input: GetUserInput| async move {
Ok(User {
id: input.id,
name: "Alice".to_string(),
email: "alice@example.com".to_string(),
})
});
// Generate client on server startup
rpc.generate_client_file("../frontend/src/lib/client.ts")?;
println!("✅ TypeScript client generated!");
let mut app = Ultimo::new();
app.mount_rpc("/rpc", rpc);2. Use in TypeScript
import { UltimoRpcClient } from "./lib/client";
const client = new UltimoRpcClient("/rpc");
// Fully type-safe!
const user = await client.getUser({ id: 1 });
console.log(user.name); // ✅ TypeScript autocomplete worksGenerated Client Structure
The generated client includes:
// Auto-generated type definitions
export type User = { id: number, name: string, email: string, };
export type GetUserInput = { id: number, };
export interface JsonRpcError {
code: number;
message: string;
data?: unknown;
}
export class JsonRpcClientError extends Error {
constructor(public readonly error: JsonRpcError) {
super(error.message);
this.name = "JsonRpcClientError";
}
}
// RPC client class
export class UltimoRpcClient {
private _idCounter = 0;
constructor(private baseUrl: string = "/api/rpc") {}
private nextId(): number {
return ++this._idCounter;
}
private async call<T>(method: string, params: unknown): Promise<T> {
const id = this.nextId();
const response = await fetch(this.baseUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", method, params, id }),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (data.error) {
throw new JsonRpcClientError(data.error);
}
return data.result as T;
}
async getUser(params: GetUserInput): Promise<User> {
return this.call("getUser", params);
}
/** Send a notification (fire-and-forget, no response expected) */
async notify(method: string, params?: unknown): Promise<void> {
/* ... */
}
/** Execute multiple RPC calls in a single HTTP request */
async batch(/* ... */) {
/* ... */
}
}Client Generation Timing
The generate_client_file() call runs once at application startup and does not interrupt your server. It generates the TypeScript file before the server starts listening, so there's no impact on request handling or runtime performance.
Development Workflow
For the best development experience, generate the client only in debug builds:
#[tokio::main]
async fn main() -> Result<()> {
let mut app = Ultimo::new();
let rpc = RpcRegistry::new();
// Register RPC methods — TS types are derived from the Rust types
rpc.query("getUser", |input: GetUserInput| async move { /* ... */ });
// Auto-generate TypeScript client in development
#[cfg(debug_assertions)]
{
rpc.generate_client_file("../frontend/src/lib/ultimo-client.ts")?;
println!("✅ TypeScript client regenerated");
}
app.mount_rpc("/rpc", rpc);
app.listen("127.0.0.1:3000").await
}- Automatic client updates when you modify RPC methods (just restart the server)
- No file generation overhead in production builds
- Client generation completes before server accepts connections
Where the generated file lands
generate_client_file(path) writes to exactly the path you give it, resolved
relative to your program's current working directory — the same as any
std::fs::write call. This is simpler (and different) than ts-rs's own
#[ts(export, export_to = "...")] attribute, which TS (re-exported as
ultimo::rpc::TS under client-gen) still carries if you use it directly on a
standalone type outside RpcRegistry: that attribute resolves export_to
relative to <CARGO_MANIFEST_DIR>/bindings/, not the crate root, which is a
common source of "the file didn't land where I expected" — see
RPC → Where the generated file lands
for a worked example.
Complete Example
Backend
// Cargo.toml: ultimo = { version = "...", features = ["client-gen"] }
use ultimo::prelude::*;
use ultimo::rpc::{RpcRegistry, TS};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
struct User {
id: u32,
name: String,
email: String,
}
#[derive(Debug, Deserialize, Serialize, TS)]
struct ListUsersInput {
page: Option<u32>,
limit: Option<u32>,
search: Option<String>,
}
#[derive(Debug, Serialize, TS)]
struct ListUsersOutput {
users: Vec<User>,
total: u32,
page: u32,
has_more: bool,
}
#[derive(Debug, Deserialize, Serialize, TS)]
struct CreateUserInput {
name: String,
email: String,
}
#[tokio::main]
async fn main() -> Result<()> {
let mut app = Ultimo::new();
let rpc = RpcRegistry::new();
// List users — types are derived from ListUsersInput/ListUsersOutput
rpc.query("listUsers", |input: ListUsersInput| async move {
let page = input.page.unwrap_or(1);
let limit = input.limit.unwrap_or(10);
let users = fetch_users(page, limit, input.search).await?;
let total = count_users().await?;
Ok(ListUsersOutput {
users,
total,
page,
has_more: (page * limit) < total,
})
});
// Create user
rpc.mutation("createUser", |input: CreateUserInput| async move {
validate_email(&input.email)?;
let user = create_user(input).await?;
Ok(user)
});
// Generate TypeScript client
rpc.generate_client_file("../frontend/src/lib/ultimo-client.ts")?;
// Mount RPC endpoint — single call, handles single/batch/notification requests
app.mount_rpc("/rpc", rpc);
app.listen("127.0.0.1:3000").await
}Generated TypeScript Client
The generate_client_file() call produces ultimo-client.ts with full type safety:
// Auto-generated TypeScript client for Ultimo RPC (JSON-RPC 2.0)
// DO NOT EDIT - This file is automatically generated
export interface JsonRpcError {
code: number;
message: string;
data?: unknown;
}
export class JsonRpcClientError extends Error {
constructor(public readonly error: JsonRpcError) {
super(error.message);
this.name = "JsonRpcClientError";
}
}
export class UltimoRpcClient {
private _idCounter = 0;
constructor(private baseUrl: string = "/api/rpc") {}
private nextId(): number {
return ++this._idCounter;
}
private async call<T>(method: string, params: unknown): Promise<T> {
const id = this.nextId();
const response = await fetch(this.baseUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", method, params, id }),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (data.error) {
throw new JsonRpcClientError(data.error);
}
return data.result as T;
}
async notify(method: string, params?: unknown): Promise<void> {
/* ... */
}
async batch(/* ... */) {
/* ... */
}
async listUsers(params: ListUsersInput): Promise<ListUsersOutput> {
return this.call("listUsers", params);
}
async createUser(params: CreateUserInput): Promise<User> {
return this.call("createUser", params);
}
}
// Type Definitions
export type User = { id: number, name: string, email: string, };
export type ListUsersInput = { page: number | null, limit: number | null, search: string | null, };
export type ListUsersOutput = { users: Array<User>, total: number, page: number, has_more: boolean, };
export type CreateUserInput = { name: string, email: string, };Frontend with React Query
import { UltimoRpcClient } from "./lib/ultimo-client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
const client = new UltimoRpcClient("/api/rpc");
function UserList() {
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
// Query: List users
const { data, isLoading, error } = useQuery({
queryKey: ["users", page, search],
queryFn: () =>
client.listUsers({
page,
limit: 10,
search: search || undefined,
}),
});
// Mutation: Create user
const createUser = useMutation({
mutationFn: (input) => client.createUser(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
},
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search users..."
/>
{data.users.map((user) => (
<div key={user.id}>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
))}
<div>
<button disabled={page === 1} onClick={() => setPage(page - 1)}>
Previous
</button>
<span>
Page {page} of {Math.ceil(data.total / 10)}
</span>
<button disabled={!data.has_more} onClick={() => setPage(page + 1)}>
Next
</button>
</div>
<button
onClick={() =>
createUser.mutate({
name: "New User",
email: "new@example.com",
})
}
>
Create User
</button>
</div>
);
}Custom Client Configuration
Base URL
// Development
const client = new UltimoRpcClient("http://localhost:3000/rpc");
// Production
const client = new UltimoRpcClient("/api/rpc");
// Environment variable
const client = new UltimoRpcClient(import.meta.env.VITE_API_URL || "/rpc");The generated client's call() method is private (not designed for
subclassing — there's no protected hook to override), but it always calls
the ambient fetch. Wrapping fetch once, before constructing the client,
is the real way to add cross-cutting behavior — headers, error transforms,
logging — to every request it makes:
Authentication
let authToken: string | null = null;
export function setAuthToken(token: string) {
authToken = token;
}
const originalFetch = window.fetch;
window.fetch = (input, init) => {
const headers = new Headers(init?.headers);
if (authToken) headers.set("Authorization", `Bearer ${authToken}`);
return originalFetch(input, { ...init, headers });
};
// Usage
setAuthToken("your-jwt-token");
const client = new UltimoRpcClient("/api/rpc");
const user = await client.getUser({ id: 1 });Error Handling
try {
const user = await client.getUser({ id: 1 });
} catch (error) {
if (error instanceof JsonRpcClientError && error.error.code === -32001 /* your app's auth-error code */) {
window.location.href = "/login";
}
console.error("RPC Error:", error);
throw error;
}Request Interceptors
let requestId = 0;
const originalFetch = window.fetch;
window.fetch = async (input, init) => {
const id = ++requestId;
const start = performance.now();
console.log(`[${id}] →`, init?.body);
const response = await originalFetch(input, init);
console.log(`[${id}] ← (${(performance.now() - start).toFixed(2)}ms)`, response.status);
return response;
};React Hooks (TanStack Query)
Ultimo generates typed TanStack Query hooks from your RPC registry — no need
to hand-write useQuery / useMutation. Emit them alongside the client in your
generate-client binary:
rpc.generate_client_file("./src/lib/client.ts")?;
rpc.generate_react_hooks_file("./src/lib/hooks.ts")?;Wrap your app once, inside your existing QueryClientProvider:
import { QueryClientProvider } from "@tanstack/react-query";
import { UltimoProvider } from "./lib/hooks";
import { UltimoRpcClient } from "./lib/client";
<QueryClientProvider client={queryClient}>
<UltimoProvider client={new UltimoRpcClient()}>
<App />
</UltimoProvider>
</QueryClientProvider>;Then use the generated hooks — input and output are fully typed, inferred from your Rust types:
import { useGetUser, useCreateUser } from "./lib/hooks";
import { useQueryClient } from "@tanstack/react-query";
function UserProfile({ id }: { id: number }) {
const { data: user, isLoading } = useGetUser({ id }); // user: User | undefined
if (isLoading) return <div>Loading…</div>;
return <h1>{user!.name}</h1>;
}
function CreateUser() {
const queryClient = useQueryClient();
const create = useCreateUser({
// invalidate cached getUser queries after a successful create
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["getUser"] }),
});
return <button onClick={() => create.mutate({ name: "Ada" })}>Add</button>;
}Each query procedure becomes a useX hook backed by useQuery; each
mutation becomes a useX hook backed by useMutation. A queryKeys export
provides stable keys for cache invalidation (queryKeys.getUser({ id })).
Requires @tanstack/react-query v5 in your frontend.
Testing
Mock Client
// __tests__/mocks/ultimo-client.ts
export class MockUltimoRpcClient {
async getUser(params: { id: number }) {
return {
id: params.id,
name: "Test User",
email: "test@example.com",
};
}
async listUsers(params: any) {
return {
users: [
{ id: 1, name: "User 1", email: "user1@example.com" },
{ id: 2, name: "User 2", email: "user2@example.com" },
],
total: 2,
page: params.page || 1,
has_more: false,
};
}
async createUser(params: any) {
return {
id: 999,
name: params.name,
email: params.email,
};
}
}Test with MSW
// __tests__/setup.ts
import { setupServer } from "msw/node";
import { rest } from "msw";
const server = setupServer(
rest.post("/rpc", async (req, res, ctx) => {
const { method, params, id } = await req.json();
if (method === "getUser") {
return res(
ctx.json({
jsonrpc: "2.0",
result: { id: params.id, name: "Test User", email: "test@example.com" },
id,
})
);
}
return res(
ctx.json({
jsonrpc: "2.0",
error: { code: -32601, message: `Procedure '${method}' not found` },
id,
})
);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());Best Practices
✅ Regenerate on Backend Changes
Add a script to regenerate the client:
# scripts/generate-client.sh
#!/bin/bash
cd backend
cargo run --bin generate-client
echo "✅ TypeScript client updated"✅ Version Your Client
The generated file is regenerated on every backend change (see "Regenerate on
Backend Changes" above) — don't hand-edit it. To tag requests with a client
version, use the same fetch-wrapping technique as Custom Client
Configuration:
export const CLIENT_VERSION = "1.0.0";
const originalFetch = window.fetch;
window.fetch = (input, init) => {
const headers = new Headers(init?.headers);
headers.set("X-Client-Version", CLIENT_VERSION);
return originalFetch(input, { ...init, headers });
};✅ Handle Network Errors
try {
const user = await client.getUser({ id: 1 });
} catch (error) {
if (error.message.includes("NetworkError")) {
// Handle offline
showOfflineMessage();
} else {
// Handle other errors
showErrorMessage(error.message);
}
}✅ Cache Responses
const { data } = useQuery({
queryKey: ["user", id],
queryFn: () => client.getUser({ id }),
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
});