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, then the page declarations: Pages, SharedProps, Errors, PageProps<C> and, unless the project declares its own (see Upgrading a project with its own augmentation), the @inertiajs/core augmentation - the file is 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
# Type wide integers as `number | bigint` (see Type mapping)
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 watches src/ and regenerates the
props file when a .rs file is created, written, or deleted; when the
project has a lang/ directory, it watches that tree too and regenerates
lang-keys.ts on the same kinds of change to a .ftl file. Reads don't
count - the generator reads every .rs file under src/ on each run, and
treating that as a change would make the watcher regenerate forever. A
burst of saves coalesces into a single run 500 ms after the last write,
so a cargo fmt or a branch switch regenerates once, from the finished
tree, rather than once per file from a half-written one.
Every run writes a file only when the emitted content differs from what is
already there. A regeneration that changed nothing reports
<path> is up to date and leaves the file, and its timestamp, untouched -
which is what keeps suprnova serve from restarting your backend over a
file it decided not to write.
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.
import '@inertiajs/core';
export interface HomeProps {
title: string;
message: string;
count: number;
tags: Array<string>;
avatar_url: string | null;
}
// ...then the page declarations, below
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.
Page types
After the props interfaces, the file maps each page component to the interface it is rendered with:
export interface Pages {
"Dashboard": DashboardProps;
"Home": HomeProps;
"auth/Login": LoginProps;
}
export type Errors = Record<string, string>;
export type PageProps<C extends keyof Pages> = Pages[C] & SharedProps & { errors: Errors };
The generator reads the pairs from your handlers. A component gets an entry when it is rendered with a struct:
inertia_response!(&req, "Home", HomeProps { .. }), or withHomeProps::new(..)or a unit struct in the props position.InertiaResponse::new("Users/Show")orInertia::paginate("Users/Index", "users", page)with.with_data(UserDto { .. })or.try_with_data(..)anywhere in the same chain.Inertia::data("Users/Show", UserDto { .. })andInertia::try_data.
In any of those positions, a local name counts when the generator can
tell which struct it holds: a let or a parameter that declares the type,
or a let that builds the struct:
pub async
// A parameter typed with the struct counts the same way.
pub async
The generator follows Rust's scoping: a later let of the same name
replaces the earlier one, and a block's names end with the block. A name
it cannot type, such as a match arm's binding or let props = load();,
leaves the page without an entry.
A field the chain defers or marks optional after it gives the struct is absent from the first response, so its key is optional in the struct's interface:
new
.with_data
.defer
.optional
export interface DashboardProps {
greeting: string;
stats?: Stats;
recent_notes?: Array<NoteSummary>;
}
.defer(..), .defer_with(..) and .optional(..) count when the key is a
string literal. A key deferred before .with_data(..) is sent with the
struct, so it stays required.
A component rendered with JSON-like props
(inertia_response!(&req, "About", { "team_size": 4 })) or only through
.with(..) has no entry, and props a chain adds with .with(..) beside
the struct are not in the type. A struct that derives only Serialize
works too: rendering a page with it emits its interface. A generic struct
gets no entry, since Pages would need its type arguments.
Entries are sorted by component name. A component rendered with two
different structs is an error: generate-types names both and the files
that render them, exits non-zero, and leaves the file as it was.
PageProps<'Home'> is what Inertia hands the Home page: its own props,
the shared props and the validation errors. Type the page's props with
it in Svelte and React:
<!-- frontend/src/pages/Home.svelte -->
<script lang="ts">
import type { PageProps } from '../types/inertia-props'
let { title, message, root }: PageProps<'Home'> = $props()
</script>
// frontend/src/pages/Home.tsx
import type { PageProps } from '../types/inertia-props'
export default function Home({ title, message, root }: PageProps<'Home'>) {
return <a href={`${root}/about`}>{title}: {message}</a>
}
Vue reads a defineProps type when it builds the component, and its
compiler cannot resolve the generic Pages[C]. Name the entry instead, and
read the shared props through usePage():
<!-- frontend/src/pages/Home.vue -->
<script setup lang="ts">
import { usePage } from '@inertiajs/vue3'
import type { Pages } from '../types/inertia-props'
defineProps<Pages['Home']>()
const { root } = usePage().props
</script>
PageProps<C> types a page's props. It is not a type argument for
usePage: Inertia requires that argument to have an index signature, and a
generated interface has none. Call usePage() without one, as the next
section shows.
Shared props and the Inertia config
The file ends with the shared props and an augmentation of
@inertiajs/core:
export interface SharedProps {
root: string;
}
declare module '@inertiajs/core' {
export interface InertiaConfig {
sharedPageProps: SharedProps;
errorValueType: string;
}
}
SharedProps always holds root, the public root the framework's
RootShare gives every page (see Frontend Overview). Its
other fields come from one struct: the one marked
#[inertia_props(shared)], or the #[derive(Data)] struct you hand to
Inertia::share_data:
use InertiaProps;
export interface SharedProps {
root: string;
app_name: string;
user_id: number | null;
}
The marker only tells the generator which struct it is; sharing the values
at run time stays your bootstrap's job. A lazy field of a shared Data
object is left out, as Inertia::share_data leaves it out. Per-key shares
(Inertia::share("appName", "Suprnova")) and shared-data providers such
as LocaleShare are not typed: the generator sees a key and a value
expression, not a declared shape. They reach usePage().props as
unknown; move them into the shared struct to type them.
#[inertia_props(flash)] marks the struct of your flash data, and the
augmentation names it as Inertia's flashDataType:
flashDataType: Toast;
Without a flash struct, Inertia's own flash type applies. Two structs for
the shared props, or two for the flash data, is an error that names both.
The markers go on an InertiaProps struct only, once, and one struct
cannot carry both: anything else fails the build.
The augmentation merges into @inertiajs/core's InertiaConfig, so
usePage() is typed with no argument in every adapter:
import { usePage } from '@inertiajs/svelte' // or /react, /vue3
const { root } = usePage().props // root: string
errorValueType: string types each validation error as the one message
per field that Suprnova sends by default. With
InertiaConfig::with_all_errors(true), a field carries every message as an
array, and the generator types it that way: when any .rs file under
src/ calls with_all_errors(..) with anything but a literal false,
Errors is Record<string, string[]> and errorValueType is string[]:
// src/bootstrap.rs: every field's errors become `string[]`
install?;
export type Errors = Record<string, string[]>;
declare module '@inertiajs/core' {
export interface InertiaConfig {
sharedPageProps: SharedProps;
errorValueType: string[];
}
}
The call is read the way preserve_big_integers(..) is (see "Wide
integers" below): true or a variable turns it on, and false, in
parentheses or not, leaves string.
The starter kits declare @inertiajs/core in package.json, since the
augmentation names it, and their tsconfig.json includes src/types/, so
the augmentation is part of every type check. The file declares Pages,
SharedProps, Errors and PageProps, so a project struct with one of
those names is an error: rename the struct.
Upgrading a project with its own augmentation
A project that typed usePage() before the generator did has its own
declare module '@inertiajs/core' block, often in
frontend/src/global.d.ts. The generator does not write a second one
beside it: two augmentations merge key by key, and a key both set to
different types fails the type check. When a .ts, .tsx, .mts or
.cts file under frontend/src (declaration files such as .d.ts
included), other than the generated file itself, declares
module '@inertiajs/core' (in single or double quotes), inertia-props.ts
keeps Pages, SharedProps, Errors and PageProps and writes no
augmentation and no import '@inertiajs/core'. generate-types says so,
naming the file:
→ frontend/src/global.d.ts declares module '@inertiajs/core', so the project's own augmentation types usePage() and the generated types add none; remove that declaration to use the generated one
serve prints the same line once, at start-up, and a regeneration on save
repeats it only when another file declares the module. A .vue or
.svelte script block is not read: an ambient module declaration belongs
in a TypeScript file. A declaration inside a comment or a string does not
count, so a commented-out // declare module '@inertiajs/core' {} leaves
the generated augmentation in place. A TypeScript file under
frontend/src that cannot be read stops the generation with its path, as
an unreadable Rust source does, since it may hold the declaration.
You can go either way:
- Keep the hand-written block. It types
usePage()as it did, and the generator stays out of its way; keep it in step with the server yourself,errorValueTypeincluded. - Delete the block, or the file when it holds nothing else, and run
suprnova generate-types. The generated augmentation then typesusePage()withroot, the shared struct's fields and the errors the server sends.
Type mapping
| Rust | TypeScript | Notes |
|---|---|---|
String, &str |
string |
|
i8, i16, i32, u8, u16, u32, f32, f64 |
number |
|
i64, u64, i128, u128, isize, usize |
number, or number | bigint when the project preserves big integers |
See "Wide integers" below. As a map key, always 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> |
|
serde_json::Value |
JsonValue |
A recursive alias the generator declares once at the top of the file, and only when something references it. A bare Value maps here too, unless the project defines its own Value struct - that one wins |
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 |
chrono::DateTime<Tz>, NaiveDate, NaiveDateTime, NaiveTime |
string |
chrono serializes them as ISO 8601 text |
| A struct with named fields the project defines | its interface, by name | Derived or not; see "Custom types" below |
| Anything else | unknown |
An external type, an enum or a tuple struct; generate-types warns. See "Custom types" below |
Wide integers
A wide integer can pass 2^53, the largest integer a JavaScript number
holds exactly. With big-integer preservation on, the server sends such a
value as a {"$bigint": "..."} marker and the Inertia client turns it
into a BigInt. So the generator types i64, u64, i128, u128,
isize and usize as number | bigint when any .rs file under src/
calls preserve_big_integers(..) with anything but a literal false,
such as true or a variable, and as number otherwise:
// src/bootstrap.rs: every wide integer field becomes `number | bigint`
install?;
A literal false turns nothing on, in parentheses too: (false) is still
false.
A map key stays number: a JSON object key is a string on the wire,
never a marker, and TypeScript refuses bigint as a Record key.
When the call sits where the scan does not read, such as another crate,
pass --big-integers. Pass it to suprnova serve as well: its start-up
generation and every regeneration on save then type the wide integers the
same way.
Custom types
The visitor recognises the primitives in the table above and chrono's date and time types. Any other type name resolves one of two ways.
A struct your project defines gets its own interface, derived or not.
A nested struct that only derives Serialize is emitted as soon as a prop
reaches it, directly or through another struct, under the keys serde
writes:
use Serialize;
use ;
Generated output:
export interface ProfileProps {
user_name: string;
company: Company;
}
export interface Company {
name: string;
address: Address;
}
export interface Address {
street: string;
city: string;
}
A struct that no prop reaches stays out of the file. Only a struct with named fields counts: an enum or a tuple struct of your own has no shape the generator can write, and falls under the next rule.
chrono's types are strings, and any other external type is unknown.
chrono serializes DateTime<Tz>, NaiveDate, NaiveDateTime and
NaiveTime as ISO 8601 text, so they emit as string. A type the
generator cannot see - uuid::Uuid, rust_decimal::Decimal, any other
crate's type, your own enum or tuple struct - emits as unknown, so the
file never names a type it does not declare, and generate-types warns
once per type:
export interface EventProps {
starts_at: string;
day: string;
id: unknown;
price: unknown;
}
⚠ Prop type `Uuid` (referenced by `EventProps.id`) isn't a struct this project defines - emitting `unknown`. ...
unknown makes the frontend check the value before it uses it. When the
value travels as a string anyway, as a Uuid does, make the prop field a
String (id: event.id.to_string()) and it is typed string. For a
shape of its own, mirror it as a struct with named fields in your project.
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;
component?: string;
}
export interface UserShowParams {
id: string;
}
export const controllers = {
home: {
index: (): RouteConfig => ({ url: '/', method: 'get', component: 'Home' }),
},
user: {
index: (): RouteConfig => ({ url: '/users', method: 'get', component: 'Users/Index' }),
show: (params: UserShowParams): RouteConfig =>
({ url: `/users/${encodeURIComponent(String(params.id))}`, method: 'get', component: 'Users/Show' }),
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.
A helper carries component when its route renders one page the
generator can name: the handler's body names exactly one component, as a
string literal in inertia_response!, InertiaResponse::new,
Inertia::data, Inertia::try_data or Inertia::paginate, or the route
is a Router::inertia(path, component, props) route. That is Inertia's
UrlMethodPair.component, which instant visits use: <Link href={...} instant> and <Form action={...} instant> render that page at once with
the current shared props and fill in the page's own props when the
response arrives. A plain router.visit ignores it. A handler that names
no component, several, or one it computes at run time gets no
component, and an instant visit through its helper falls back to an
ordinary one.
A Router::inertia route has no controller, so its helper sits under
controllers.inertia, keyed by its path (/about is
controllers.inertia.about, / is controllers.inertia.index), and its
.name(..) reaches it through routes like any other.
A handler inside an impl block is read like a free function. For
#[handler(Self = Posts)] on index inside impl Posts, registered as
get!("/users", controllers::posts::Posts::index), the generator reads
the method from impl Posts in the file of controllers::posts, for its
request type and its component alike. A free index in the same file
keeps its own helper. The method's helper sits under the type's name,
controllers.Posts.index.
The helpers build the same URLs the backend's route() helper builds:
- Each parameter value is percent-encoded as one path segment. A slug of
a/bbecomes/posts/a%2Fband reaches the handler asa/b, and a?or#in a value cannot start a query string or a fragment. - A catch-all parameter (
{*rest}) is namedrestand keeps its slashes. Each segment is encoded on its own. - An optional parameter (
{year?}) is an optional key. Without a value it is left out with its segment, so/archive/{year?}with no year is/archive. A helper whose parameters are all optional can be called with no argument. - A route inside
group!gets the group's path prefix and its.name(...)prefix, nested groups included. A bare handler name insidegroup!(..., controller = ..., { ... })resolves to that controller. - A request type's interface uses the keys serde reads, so
rename,rename_allandskip_deserializingapply. - When two routes share one handler, the second gets its own helper key, and
its entry in
routespoints at that helper.
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.
Inertia types usePage() through a declare module '@inertiajs/core'
block you write by hand, in a global.d.ts, and keep in step with the
server. Suprnova generates that block into inertia-props.ts, from the
struct your handlers share and the struct you mark as flash data, so the
shared props cannot drift from the server either. A project that already
has a hand-written block keeps it, and the generator steps aside: it
writes no augmentation of its own, since the two would merge and a key
both set to different types is a type error (see Upgrading a project with
its own augmentation).
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
