suprnova generate-types scans your Rust source for #[derive(InertiaProps)]
and #[derive(Data)] structs and writes TypeScript declarations the
frontend can import. Run it after you change props or routes; suprnova serve runs it for you on boot unless you pass --skip-types.
What gets generated
Three files, written into frontend/src/types/:
| File | Contents |
|---|---|
inertia-props.ts |
One export interface per prop struct - always written |
routes.ts |
A controllers object and routes named-lookup map derived from src/routes.rs - written only with --routes |
lang-keys.ts |
A MessageKey string-literal union of every Fluent message id in the default locale's catalog, fallback parents included - written only when lang/ yields any ids (see Message keys) |
All three start with a header comment marking them auto-generated. Don't edit them by hand - your changes are overwritten on the next run.
Running it
# One-shot
# Re-emit on every Rust file change
# Custom output for the props file
The route file path is fixed at frontend/src/types/routes.ts and the
message-key file at frontend/src/types/lang-keys.ts; only the props
file path is configurable. The watcher polls src/ and regenerates the
props file on any .rs change; when the project has a lang/
directory, it also watches that tree and regenerates lang-keys.ts on
any .ftl change.
Page props
The expected shape: one Rust struct per Inertia page, deriving
InertiaProps, paired with a page component that types its props off the
generated interface.
// src/controllers/home.rs
use ;
pub async
suprnova generate-types emits:
// frontend/src/types/inertia-props.ts
// This file is auto-generated by Suprnova. Do not edit manually.
// Run `suprnova generate-types` to regenerate.
export interface HomeProps {
title: string;
message: string;
count: number;
tags: Array<string>;
avatar_url: string | null;
}
Consume it the same way regardless of frontend framework - the
import type line is identical across Svelte, React, and Vue:
<!-- frontend/src/pages/Home.svelte (the default scaffold) -->
<script lang="ts">
import type { HomeProps } from '../types/inertia-props'
let { title, message, count, tags, avatar_url }: HomeProps = $props()
</script>
<h1>{title}</h1>
<p>{message} - {count} items</p>
{#if avatar_url}<img src={avatar_url} alt="" />{/if}
See Page Components for the React and Vue equivalents.
Type mapping
| Rust | TypeScript | Notes |
|---|---|---|
String, &str |
string |
|
i8..i128, u8..u128, isize, usize, f32, f64 |
number |
All numeric primitives collapse to number |
bool |
boolean |
|
Option<T> |
T | null |
|
Vec<T> |
Array<T> |
The props generator emits Array<T>; the routes generator emits T[] for form-request fields |
HashMap<K, V>, BTreeMap<K, V> |
Record<K, V> |
|
Field<T> (from #[derive(Data)]) |
field?: T | null |
Field is optional on the wire |
Prop<T> (lazy / deferred) |
field?: T |
Lazy props omit the null half |
| Anything else | bare identifier | See "Custom types" below |
Custom types
The visitor recognises the primitives in the table above. Everything else emits as its bare Rust type name. Two consequences fall out of that:
A nested struct gets its own interface only if it derives InertiaProps
or Data. A bare #[derive(Serialize)] struct is invisible to the
generator - the emitted interface will reference it by name, but no
declaration is produced:
use Serialize;
use ;
// OR InertiaProps - either works
Generated output:
export interface Company {
name: string;
address: Address; // dangling - no Address interface emitted
}
export interface ProfileProps {
user_name: string;
company: Company;
}
To fix: add #[derive(Data)] (or InertiaProps) to Address. The
serialise behaviour at runtime is unaffected by the derive choice on the
nested struct - serde_json will still emit the struct correctly - but
TypeScript needs the explicit declaration.
Generic args on custom types are not recursed. chrono::DateTime<Utc>,
uuid::Uuid, rust_decimal::Decimal, and any of your own non-derived
types emit as the bare leading identifier (DateTime, Uuid, Decimal).
They serialise to JSON strings on the wire, but the TypeScript declaration
won't say so. Two ways to handle this:
// frontend/src/types/runtime-types.ts - handwritten alongside the generated file
export type DateTime = string; // chrono::DateTime<_> serialises as RFC 3339
export type Uuid = string;
export type Decimal = string; // serde_with default; rust_decimal::Decimal
Then in tsconfig.json, make sure they're picked up via your include
glob, or import type { DateTime } from './runtime-types' where needed.
The framework doesn't ship these aliases for you because the right choice
(string vs number vs branded type) depends on the serde feature flags
you've turned on.
Generic structs
A generic Rust struct becomes a generic TypeScript interface:
use ;
export interface Paginated<T> {
items: Array<T>;
total: number;
cursor?: string | null;
}
The where clause is dropped - TypeScript generics are unconstrained
unless you say otherwise on the consuming side.
Split input/output interfaces
When a #[derive(Data)] struct mixes #[data(input_only)],
#[data(output_only)], or #[data(lazy)] fields, two interfaces are
emitted - the output shape (what the frontend receives) and an <Name>Input
counterpart (what it sends back):
use Data;
use Field;
use Prop;
use Validate;
Emits both:
export interface UserDto { // server -> client
id: number;
name: string;
computed_handle: string;
bio?: string | null;
favorite_song?: string;
}
export interface UserDtoInput { // client -> server
id: number;
name: string;
password: string;
bio?: string | null;
}
Prop fields are output-only by nature, so they're stripped from the
input type even when lazy isn't explicit. For the full set of #[data(...)]
flags and their authoring semantics, see Data.
Type-safe routes
With --routes, the command writes a second file,
frontend/src/types/routes.ts, generated from src/routes.rs. It exposes a controllers object whose
method shapes mirror your backend module tree, plus a routes lookup
keyed by .name(...):
// src/routes.rs
use routes;
routes!
// frontend/src/types/routes.ts
import type { Method } from '@inertiajs/core';
export interface RouteConfig<TData = void> {
url: string;
method: Method;
data?: TData;
}
export interface UserShowParams {
id: string;
}
export const controllers = {
home: {
index: (): RouteConfig => ({ url: '/', method: 'get' }),
},
user: {
index: (): RouteConfig => ({ url: '/users', method: 'get' }),
show: (params: UserShowParams): RouteConfig =>
({ url: `/users/${params.id}`, method: 'get' }),
store: (): RouteConfig => ({ url: '/users', method: 'post' }),
},
} as const;
export const routes = {
'home': controllers.home.index,
'users.index': controllers.user.index,
'users.show': controllers.user.show,
'users.store': controllers.user.store,
} as const;
RouteConfig is intentionally shaped to satisfy Inertia 3's
UrlMethodPair interface, so it slots into router.visit, useForm's
submit, and <Link href={...}> without an adapter:
import { router } from '@inertiajs/svelte' // or /react, /vue
import { controllers, routes } from '../types/routes'
router.visit(controllers.home.index())
router.visit(controllers.user.show({ id: '42' }))
router.visit(routes['users.show']({ id: '42' }))
If a path has params (/users/{id}), the generated function requires the
typed Params object - TypeScript catches missing or misspelled keys at
compile time.
Message keys
If the app has a lang/ catalog tree, the generator also writes
frontend/src/types/lang-keys.ts: a MessageKey string-literal union
of every Fluent message id the frontend can resolve. The starter kits'
t() wrapper types its key argument as MessageKey, so a typo'd or
deleted key is a TypeScript error pointing at the component - the same
promise inertia-props.ts makes for props, extended to translations.
The ids come from lang/<APP_LOCALE>/*.ftl, unioned across the
locale's configured fallback parents (APP_LOCALE_PARENTS) - so a
delta-style catalog that holds only the strings it overrides still
generates the full key set the served, chain-flattened catalog
carries. APP_FALLBACK_LOCALE is deliberately not included: the
served catalog doesn't flatten the terminal fallback either, and the
union must describe exactly what the browser's one fetched catalog can
resolve. See Localization for the chain itself.
The file follows the same rules as the other two: fixed path,
auto-generated header, deterministic sorted output, never hand-edited.
When no .ftl file yields any id, the file is removed rather than
emitted empty - a kit importing MessageKey then fails to compile
until the catalog has ids again, which is loud, like every other drift
in this pipeline.
When it runs
suprnova serve triggers generate-types on boot. The default flow:
suprnova serve
├─ scan src/ for InertiaProps / Data structs → inertia-props.ts
├─ scan src/routes.rs for route definitions → routes.ts
├─ scan lang/ for Fluent message ids → lang-keys.ts
├─ start the backend
└─ start Vite
Pass --skip-types to skip both. Re-run suprnova generate-types
manually any time you change Rust code while the backend is hot-reloading
and you want fresh declarations without restarting the server.
Commit the output, and expect a clean diff
inertia-props.ts is meant to be checked in - that is what lets a
reviewer see a prop contract change in the same diff as the Rust that
caused it, and what stops CI needing a Rust toolchain to typecheck the
frontend.
Generation is deterministic: the same source produces byte-identical output, on any machine, in any order the files happen to sit on disk. So a run that changes nothing shows no diff, and any diff you do see is a real contract change. (It was not always so - the sort seeded itself from hash iteration order, and every run reshuffled the interfaces.)
Two consequences worth internalising:
- Nothing else may edit that file. If a component imports a type the
generator does not emit, the next
generate-typesdeletes it and the frontend stops compiling. The fix is to declare the type in Rust, not to hand-edit the output. - A page with no Rust route has no generated type, because there is
no
#[derive(InertiaProps)]struct behind it. Declare its props locally in the component until a handler exists to own them.
Why Suprnova diverges
Laravel doesn't ship type generation for Inertia because PHP is dynamically typed - there's nothing on the server side to extract a contract from. Inertia's Laravel community filled the gap with userland tools (Laravel Typed Inertia, Wayfinder) that read PHPDoc or DocBlock annotations.
Suprnova has the type information at compile time and on disk, so we
extract it directly. The same #[derive(InertiaProps)] or #[derive(Data)]
that controls JSON serialisation also drives the TypeScript output - one
source of truth, never out of sync with what serde actually emits.
Next
- Frontend Overview - Inertia, page components, dev server
- Page Components - Svelte 5, React 19, and Vue 3.5 consumption patterns
- Data - the authoring side of
#[derive(Data)],#[data(...)]flags,Field<T>, andProp<T> - Requests - typed request bodies and validation
- Localization - the Fluent catalogs and fallback
chains behind
lang-keys.ts - CLI Generators - every
suprnova make:*andgenerate-*command
