Back to blog
releasetypescriptrpc

Introducing Ultimo v0.5.0: Derive Your TypeScript Types from Rust

Ultimo Team6 min read

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:

  1. 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.
  2. Duplication. Every struct's shape existed in two places: the Rust definition and the registration call.
  3. Drift at scale. With dozens of RPC methods, keeping strings synchronized became a maintenance burden that grew linearly with your API surface.
  4. Hardcoded types. The generated client previously included a hardcoded User interface 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:

  1. Derive TS on your input/output structs.
  2. Register handlers with query or mutation — types are inferred automatically.
  3. 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 TypeTypeScript TypeNotes
boolboolean
u8, u16, u32, i8, i16, i32number
u64, i64, u128, i128numberConsider bigint for large values
f32, f64number
String, &strstring
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::NaiveDateTimestringWith ts-rs chrono feature
uuid::UuidstringWith ts-rs uuid feature
Enums (unit variants)"A" | "B" | "C"String literal union
Enums (data variants)Tagged union{ type: "A", data: ... }
serde_json::ValueanyEscape 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 the TS trait, which produces a string representation of the TypeScript type. This adds a few milliseconds to compilation — negligible in practice.
  • Code generation time: ultimo generate reads the TS impls and writes .ts files. This is a one-shot CLI operation, not part of your server's hot path.
  • Runtime: Zero cost. The TS trait is never invoked during request handling. Your handler's type signature is the same Fn(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.62ms

The 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/api

The 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 system

Upgrading

# 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/api

The 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 TS trait 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