Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

TypeScript Clients

Ultimo automatically generates type-safe TypeScript clients from your Rust RPC procedures, enabling seamless full-stack development.

query/mutation require the client-gen feature. Every example on this page uses them — add features = ["client-gen"] to your ultimo dependency, 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 works

Generated 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
}
Benefits:
  • 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
});