Manual contentsFrontendBrowse 103 chapters
Manual 10 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 - 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

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 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.

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

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

#[derive(Data)]      // OR InertiaProps - either works
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 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 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;
}

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-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.

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