Manual contentsFrontendBrowse 126 chapters
Manual 21 min read

TypeScript Types

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
suprnova generate-types

# Re-emit on every Rust file change
suprnova generate-types --watch

# Custom output for the props file
suprnova generate-types --output frontend/src/types/props.ts

# Type wide integers as `number | bigint` (see Type mapping)
suprnova generate-types --big-integers

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 suprnova::{InertiaProps, Request, Response, inertia_response};

#[derive(InertiaProps)]
pub struct HomeProps {
    pub title: String,
    pub message: String,
    pub count: i64,
    pub tags: Vec<String>,
    pub avatar_url: Option<String>,
}

pub async fn index(req: Request) -> Response {
    inertia_response!(&req, "Home", HomeProps {
        title: "Welcome".into(),
        message: "Hello".into(),
        count: 3,
        tags: vec!["new".into(), "featured".into()],
        avatar_url: None,
    })
}

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 with HomeProps::new(..) or a unit struct in the props position.
  • InertiaResponse::new("Users/Show") or Inertia::paginate("Users/Index", "users", page) with .with_data(UserDto { .. }) or .try_with_data(..) anywhere in the same chain.
  • Inertia::data("Users/Show", UserDto { .. }) and Inertia::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:

#[handler]
pub async fn index(req: Request) -> Response {
    let props: HomeProps = load_home(&req);
    inertia_response!(&req, "Home", props)
}

// A parameter typed with the struct counts the same way.
pub async fn render_dashboard(req: &Request, props: DashboardProps) -> Response {
    InertiaResponse::new("Dashboard").with_data(props).resolve(req).await
}

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:

InertiaResponse::new("Dashboard")
    .with_data(props)
    .defer("stats", move || note_stats(user_id))
    .optional("recent_notes", move || recent_notes(user_id))
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 suprnova::InertiaProps;

#[derive(InertiaProps)]
#[inertia_props(shared)]
pub struct AppShared {
    pub app_name: String,
    pub user_id: Option<u64>,
}
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:

#[derive(InertiaProps)]
#[inertia_props(flash)]
pub struct Toast {
    pub message: String,
}
    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[]`
Inertia::install(&InertiaConfig::new().with_all_errors(true))?;
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, errorValueType included.
  • Delete the block, or the file when it holds nothing else, and run suprnova generate-types. The generated augmentation then types usePage() with root, 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`
Inertia::install(&InertiaConfig::new().preserve_big_integers(true))?;

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 serde::Serialize;
use suprnova::{Data, InertiaProps};

#[derive(Serialize)]
pub struct Address {  // no InertiaProps or Data derive: picked up anyway
    pub street: String,
    pub city: String,
}

#[derive(Data)]
pub struct Company {
    pub name: String,
    pub address: Address,
}

#[derive(InertiaProps)]
pub struct ProfileProps {
    pub user_name: String,
    pub company: Company,
}

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:

#[derive(InertiaProps)]
pub struct EventProps {
    pub starts_at: chrono::DateTime<chrono::Utc>,
    pub day: chrono::NaiveDate,
    pub id: uuid::Uuid,
    pub price: rust_decimal::Decimal,
}
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 suprnova::{Data, data::Field};

#[derive(Data)]
pub struct Paginated<T>
where
    T: serde::Serialize + for<'de> serde::Deserialize<'de>,
{
    pub items: Vec<T>,
    pub total: usize,
    pub cursor: Field<String>,
}
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 suprnova::Data;
use suprnova::data::Field;
use suprnova::inertia::Prop;
use validator::Validate;

#[derive(Data, Validate)]
pub struct UserDto {
    pub id: i64,
    pub name: String,

    #[data(input_only)]
    #[validate(length(min = 8))]
    pub password: String,

    #[data(output_only)]
    pub computed_handle: String,

    pub bio: Field<String>,

    #[data(lazy)]
    pub favorite_song: Prop<String>,
}

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 suprnova::routes;

routes! {
    get!("/", controllers::home::index).name("home"),
    get!("/users", controllers::user::index).name("users.index"),
    get!("/users/{id}", controllers::user::show).name("users.show"),
    post!("/users", controllers::user::store).name("users.store"),
}
// 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/b becomes /posts/a%2Fb and reaches the handler as a/b, and a ? or # in a value cannot start a query string or a fragment.
  • A catch-all parameter ({*rest}) is named rest and 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 inside group!(..., controller = ..., { ... }) resolves to that controller.
  • A request type's interface uses the keys serde reads, so rename, rename_all and skip_deserializing apply.
  • When two routes share one handler, the second gets its own helper key, and its entry in routes points 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-types deletes 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>, and Prop<T>
  • Requests - typed request bodies and validation
  • Localization - the Fluent catalogs and fallback chains behind lang-keys.ts
  • CLI Generators - every suprnova make:* and generate-* command