# 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](#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

```bash
# 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.

```rust
// 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:

```typescript
// 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:

```svelte
<!-- 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](frontend-pages.md) 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:

```rust
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:

```typescript
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:

```typescript
// 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:

```rust
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>,
}
```

```typescript
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):

```rust
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:

```typescript
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](data.md).

## 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(...)`:

```rust
// 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"),
}
```

```typescript
// 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:

```ts
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](localization.md) 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](frontend.md) - Inertia, page components, dev server
- [Page Components](frontend-pages.md) - Svelte 5, React 19, and Vue 3.5
  consumption patterns
- [Data](data.md) - the authoring side of `#[derive(Data)]`,
  `#[data(...)]` flags, `Field<T>`, and `Prop<T>`
- [Requests](requests.md) - typed request bodies and validation
- [Localization](localization.md) - the Fluent catalogs and fallback
  chains behind `lang-keys.ts`
- [CLI Generators](cli-generators.md) - every `suprnova make:*` and
  `generate-*` command
