Introducing Ultimo v0.5.0: Derive Your TypeScript Types from Rust
Every full-stack Rust developer knows the pain: you change a field in your Rust struct, forget to update the TypeScript interface, and your frontend silently breaks at runtime. Ultimo v0.5.0 eliminates that entire class of bugs by deriving your TypeScript types directly from Rust — at compile time, with zero runtime cost.
This release also ships static file serving, response compression, and a streamlined RPC registration API. Let's dig in.
The Problem: Manual Type Synchronization
Before v0.5.0, Ultimo's TypeScript client generation required you to manually specify type signatures as strings when registering RPC handlers:
// Before v0.5.0 — manual type strings
registry.query_with_types(
"getUser",
"{ id: number }",
"{ id: number; name: string; email: string }",
|ctx, req: RpcRequest| async move {
let input: GetUserInput = req.parse_params()?;
let user = fetch_user(input.id).await?;
Ok(RpcResponse::success(req.id, user))
},
);This approach had several problems:
- No compiler enforcement. The string
"{ id: number }"has no relationship to your actual Rust type. Change the struct, forget the string — your generated client lies to consumers. - Duplication. Every struct's shape existed in two places: the Rust definition and the registration call.
- Drift at scale. With dozens of RPC methods, keeping strings synchronized became a maintenance burden that grew linearly with your API surface.
- Hardcoded types. The generated client previously included a hardcoded
Userinterface that polluted every project's type namespace whether they used it or not.
We needed a solution where the Rust compiler itself guarantees that your TypeScript types match your Rust types — always.
The Solution: #[derive(TS)] + Automatic RPC Registration
Ultimo v0.5.0 integrates ts-rs (upgraded to v12) behind the new client-gen Cargo feature. The workflow is simple:
- Derive
TSon your input/output structs. - Register handlers with
queryormutation— types are inferred automatically. - Run
ultimo generate— the emitted TypeScript client includes real type declarations derived from your Rust code.
use ultimo::prelude::*;
use ultimo::rpc::{RpcRegistry, RpcRequest, RpcResponse, TS};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, TS)]
struct GetUserInput {
id: u64,
}
#[derive(Debug, Serialize, TS)]
struct UserOutput {
id: u64,
name: String,
email: String,
created_at: String,
}
let mut registry = RpcRegistry::new();
// v0.5.0 — types derived from Rust structs automatically
registry.query::<GetUserInput, UserOutput>(
"getUser",
|ctx, req: RpcRequest| async move {
let input: GetUserInput = req.parse_params()?;
let user = fetch_user(input.id).await?;
Ok(RpcResponse::success(req.id, user))
},
);The generated TypeScript client now emits:
// Auto-generated by ultimo generate — do not edit
export type GetUserInput = {
id: number;
};
export type UserOutput = {
id: number;
name: string;
email: string;
created_at: string;
};
export class UltimoClient {
async getUser(params: GetUserInput): Promise<UserOutput> {
return this.call("getUser", params);
}
}No manual type strings. No drift. The Rust compiler enforces the TS bound on both I and O at the query/mutation call site — if your struct doesn't derive TS, it won't compile.
Enabling the Feature
The client-gen feature is opt-in. Add it to your Cargo.toml:
[dependencies]
ultimo = { version = "0.5", features = ["client-gen"] }This pulls in ts-rs as a dependency and re-exports the TS derive macro as ultimo::rpc::TS. If you don't use TypeScript client generation, you pay nothing — ts-rs is no longer a hard dependency.
Type Mapping: Rust → TypeScript
When you derive TS, each Rust type maps to a TypeScript equivalent. Here's the complete mapping table for common types:
| Rust Type | TypeScript Type | Notes |
|---|---|---|
bool | boolean | |
u8, u16, u32, i8, i16, i32 | number | |
u64, i64, u128, i128 | number | Consider bigint for large values |
f32, f64 | number | |
String, &str | string | |
Option<T> | T | null | |
Vec<T> | T[] | |
HashMap<K, V> | Record<K, V> | K must be string-like |
HashSet<T> | T[] | |
(A, B) | [A, B] | Tuple types preserved |
chrono::NaiveDateTime | string | With ts-rs chrono feature |
uuid::Uuid | string | With ts-rs uuid feature |
| Enums (unit variants) | "A" | "B" | "C" | String literal union |
| Enums (data variants) | Tagged union | { type: "A", data: ... } |
serde_json::Value | any | Escape hatch |
Nested structs are resolved transitively — if UserOutput contains a Vec<Role> and Role derives TS, the generated client emits the Role type declaration automatically.
Migration Guide: The Breaking Change
The query and mutation methods on RpcRegistry now take (name, handler) and derive TypeScript types from the generic parameters I: TS and O: TS. The previous string-typed signatures are preserved as query_with_types and mutation_with_types.
If you're already using typed structs
Replace string type annotations with generic parameters:
// Before (v0.4.x)
registry.query_with_types(
"listPosts",
"{ page: number; limit: number }",
"{ posts: Post[]; total: number }",
handler,
);
// After (v0.5.0)
// 1. Add #[derive(TS)] to your structs
#[derive(Deserialize, TS)]
struct ListPostsInput { page: u32, limit: u32 }
#[derive(Serialize, TS)]
struct ListPostsOutput { posts: Vec<Post>, total: u64 }
// 2. Use the new query method
registry.query::<ListPostsInput, ListPostsOutput>("listPosts", handler);If you need the old behavior temporarily
The old API is fully preserved — just rename your calls:
// This still works in v0.5.0
registry.query_with_types(
"listPosts",
"{ page: number; limit: number }",
"{ posts: Post[]; total: number }",
handler,
);We recommend migrating to #[derive(TS)] incrementally. You can mix both styles in the same registry during the transition.
Removing the hardcoded User interface
The generated client no longer includes a hardcoded User interface. If your frontend depends on it, define your own:
#[derive(Serialize, TS)]
struct User {
id: u64,
name: String,
email: String,
}This will be emitted as part of the generated types when referenced by any registered RPC method.
Static File Serving
Ultimo v0.4.1 (shipped as part of the v0.5 milestone) added first-class static file serving and SPA fallback:
use ultimo::prelude::*;
let app = Ultimo::new()
.serve_static("/assets", "./public/assets")
.serve_spa("/app", "./dist", "index.html")
.build();serve_static maps a URL prefix to a directory on disk, serving files with correct MIME types, Cache-Control headers, and ETag-based conditional responses. serve_spa does the same but falls back to a specified index file for any path that doesn't match a real file — exactly what single-page applications need.
Both methods integrate with the existing middleware stack, so your auth guards, CORS headers, and logging apply uniformly to static and dynamic routes alike.
// Serve a React/Vue/Svelte SPA with API routes
let app = Ultimo::new()
.middleware(SecurityHeaders::default())
.middleware(Compression::default())
.serve_spa("/", "./frontend/dist", "index.html")
.route("/api/v1/users", get(list_users))
.route("/api/v1/users/:id", get(get_user))
.build();Response Compression
Also shipping in v0.4.1, the Compression middleware automatically compresses response bodies using gzip or Brotli based on the client's Accept-Encoding header:
use ultimo::prelude::*;
use ultimo::middleware::Compression;
let app = Ultimo::new()
.middleware(Compression::default()) // gzip + brotli, threshold 1024 bytes
.build();Configuration options:
let compression = Compression::builder()
.gzip(true) // Enable gzip (default: true)
.brotli(true) // Enable brotli (default: true)
.threshold(512) // Minimum body size to compress (bytes)
.build();Compression respects Content-Type — it won't compress already-compressed formats like images, videos, or archives. For JSON-heavy APIs and HTML responses, expect 60-80% size reduction on typical payloads.
Performance Impact: Zero Runtime Cost for Type Derivation
A common concern with code generation: does it slow things down? The answer for #[derive(TS)] is no — the type information is computed at compile time and written to disk by the ultimo generate CLI command. At runtime, your handlers execute exactly as before with no additional overhead.
Here's what happens at each stage:
- Compile time:
#[derive(TS)]generates an impl of theTStrait, which produces a string representation of the TypeScript type. This adds a few milliseconds to compilation — negligible in practice. - Code generation time:
ultimo generatereads theTSimpls and writes.tsfiles. This is a one-shot CLI operation, not part of your server's hot path. - Runtime: Zero cost. The
TStrait is never invoked during request handling. Your handler's type signature is the sameFn(Context, RpcRequest) -> Future<RpcResponse>it always was.
Benchmarks confirm no measurable regression. Our standard JSON-RPC throughput test (10,000 concurrent requests, 1KB payloads) shows identical numbers between v0.4.x and v0.5.0:
# v0.4.1 baseline
Requests/sec: 158,423
Avg latency: 0.63ms
# v0.5.0 with #[derive(TS)] on all types
Requests/sec: 158,891
Avg latency: 0.62msThe variance is within noise. Type derivation is purely a developer-experience improvement with no production cost.
Putting It All Together
Here's a complete example combining the new features — a typed RPC API with static file serving and compression:
use ultimo::prelude::*;
use ultimo::rpc::{RpcRegistry, RpcRequest, RpcResponse, TS};
use ultimo::middleware::Compression;
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, TS)]
struct CreatePostInput {
title: String,
body: String,
tags: Vec<String>,
}
#[derive(Debug, Serialize, TS)]
struct PostOutput {
id: u64,
title: String,
body: String,
tags: Vec<String>,
published: bool,
created_at: String,
}
#[tokio::main]
async fn main() {
let mut registry = RpcRegistry::new();
registry.mutation::<CreatePostInput, PostOutput>(
"createPost",
|ctx, req: RpcRequest| async move {
let input: CreatePostInput = req.parse_params()?;
let post = create_post(input).await?;
Ok(RpcResponse::success(req.id, post))
},
);
let app = Ultimo::new()
.middleware(Compression::default())
.rpc("/rpc", registry)
.serve_spa("/", "./frontend/dist", "index.html")
.build();
app.listen("0.0.0.0:3000").await.unwrap();
}Generate your TypeScript client:
cargo run -p ultimo-cli -- generate --path ./src --output ./frontend/src/apiThe emitted client is ready to import:
import { UltimoClient } from "./api/client";
const client = new UltimoClient("http://localhost:3000/rpc");
const post = await client.createPost({
title: "Hello, Ultimo v0.5!",
body: "Type-safe from Rust to TypeScript.",
tags: ["rust", "typescript", "web"],
});
console.log(post.id); // number — guaranteed by the type systemUpgrading
# Update your dependency
cargo update -p ultimo
# If using client-gen, enable the feature
# Cargo.toml: ultimo = { version = "0.5", features = ["client-gen"] }
# Regenerate your TypeScript client
cargo run -p ultimo-cli -- generate --path ./src --output ./frontend/src/apiThe only breaking change is the query/mutation method signature. If you're using the old string-typed API, your code still compiles — just rename to query_with_types/mutation_with_types. We strongly recommend migrating to #[derive(TS)] to get full compile-time type safety.
What's Next
Ultimo v0.5.0 completes the core developer experience story: you write Rust structs once, and both your server and your TypeScript client understand the same types. Looking ahead:
- v0.5.x patches: Multi-language client generation (Python, Swift, Kotlin) using the same
TStrait infrastructure generalized to other targets. - v0.6.0: MCP server for AI-assisted development — let your editor understand your Ultimo API surface natively.
- v1.0.0: Stable API commitment, complete documentation, and production deployment guides.
We're building Ultimo to be the framework where Rust's type system isn't just a backend luxury — it's a full-stack guarantee. v0.5.0 is the release that makes that real.
Get started: Build your first API · Docs: docs.ultimo.dev · Source: github.com/ultimo-rs/ultimo · Install: cargo add ultimo