An Inertia page gets its props from a controller. Typelizer lets you declare those props once, next to the controller that renders the page. From the declaration it generates one TypeScript props interface per page component. In dev and test, a plug checks that each rendered page sends exactly the declared props.

Typelizer works with the inertia package (~> 2.6).

Declare the props of a page

defmodule MyAppWeb.TaskController do
  use MyAppWeb, :controller
  use Typelizer.InertiaPage

  page "tasks/index",
    props: [
      tasks: {:list, MyAppWeb.TaskSerializer},
      filters: :map,
      next_cursor: {:nullable, :string},
      stats: {:optional, {:object, open: :integer, done: :integer}}
    ]

  page "tasks/show", props: [task: MyAppWeb.TaskSerializer, can_edit: :boolean]

  def index(conn, params) do
    conn
    |> assign_prop(:tasks, MyAppWeb.TaskSerializer.serialize_many(list_tasks(params)))
    |> assign_prop(:filters, params)
    |> assign_prop(:next_cursor, nil)
    |> assign_prop(:stats, inertia_defer(fn -> task_stats() end))
    |> render_inertia("tasks/index")
  end
end

page/2 takes the component name (the same string you pass to render_inertia/2) and these options:

OptionMeaning
props:Required. A keyword list: prop name → type spec.
name:The TypeScript interface name. Default: the component name in PascalCase plus Props: "tasks/index" → TasksIndexProps, "settings/profile-edit" → SettingsProfileEditProps.
  • {:optional, spec} makes the key optional (stats?: …). Use it for props that are not always sent, such as deferred props (inertia_defer/1).
  • A serializer module as a type (or inside {:list, _}) imports its interface.
  • {:paginated, Serializer}, {:cursor_paginated, Serializer} and {:envelope, spec} type props built with Typelizer.Envelope (see Envelopes and pagination).
  • An invalid type spec is a compile error.
  • You can declare pages in any module of your app, not only in controllers. A component declared twice makes mix typelizer.gen fail.

Declare shared props

Shared props are the props that a plug adds to every page, such as the current user. Declare them once, in one module:

defmodule MyAppWeb.InertiaShared do
  use Typelizer.InertiaPage

  shared current_user: {:nullable, MyAppWeb.UserSerializer},
         locale: :string
end

The inertia package always adds errors and flash, so SharedProps always has them: errors: Record<string, string> and flash: Record<string, string>. Declare errors or flash in shared to change their type.

Two modules with shared make mix typelizer.gen fail.

Generated TypeScript

The default directory is assets/js/generated/pages. Each page component gets a <component>.props.ts file:

// assets/js/generated/pages/tasks/index.props.ts
// Generated by typelizer — do not edit.
// Source: MyAppWeb.TaskController

import type { Task } from "../../serializers/Task";
import type { SharedProps } from "../shared.props";

export interface TasksIndexProps extends SharedProps {
  tasks: Task[];
  filters: Record<string, unknown>;
  nextCursor: string | null;
  stats?: {
    open: number;
    done: number;
  };
}
// assets/js/generated/pages/shared.props.ts
// Generated by typelizer — do not edit.
// Source: MyAppWeb.InertiaShared

import type { User } from "../serializers/User";

export interface SharedProps {
  currentUser: User | null;
  locale: string;
  errors: Record<string, string>;
  flash: Record<string, string>;
}
// assets/js/generated/pages/index.ts
// Generated by typelizer — do not edit.

import type { SharedProps } from "./shared.props";
import type { TasksIndexProps } from "./tasks/index.props";
import type { TasksShowProps } from "./tasks/show.props";

export type { SharedProps, TasksIndexProps, TasksShowProps };

export interface Pages {
  "tasks/index": TasksIndexProps;
  "tasks/show": TasksShowProps;
}

The .props.ts suffix lets you put the generated files next to your page components if you prefer: output: [pages: "assets/js/pages"] writes assets/js/pages/tasks/index.props.ts next to assets/js/pages/tasks/index.tsx. Typelizer never overwrites or deletes a file that does not start with its header.

Use the types in your components:

import { usePage } from "@inertiajs/react";
import type { SharedProps, TasksIndexProps } from "@/generated/pages";

export default function Index({ tasks, filters, stats }: TasksIndexProps) {
  // …
}

function UserMenu() {
  const { currentUser } = usePage<SharedProps>().props;
  // …
}

Keys

Prop keys in TypeScript follow the key transform (:camel by default), the same as serializer keys. Tell the inertia package to camelize the keys it sends:

config :inertia, camelize_props: true

Or call camelize_props/1 in the controller.

camelize_props changes every key, at every depth, not only the prop names:

  • Serializer output with the default :camel key transform does not change: its keys are already camelCase.
  • A serializer with key_transform: :snake arrives camelCased, so its TypeScript interface (snake_case) is wrong. With camelize_props, keep the :camel key transform for serializers that you send to pages.
  • Keys that are data, not field names, change too: a map of counts %{"in_progress" => 2} arrives as { inProgress: 2 }, and a translation catalogue key "auth.session.tab_link" arrives as "auth.session.tabLink". Inertia.Controller.preserve_case/1 keeps one key as written, so wrap each data key:
catalogue = Map.new(catalogue, fn {key, text} -> {preserve_case(key), text} end)

conn
|> assign_prop(:translations, catalogue)   # "auth.session.tab_link" stays as is
|> assign_prop(:task_counts, counts)       # "in_progress" becomes "inProgress"

Wrapping the prop name (assign_prop(conn, preserve_case(:translations), …)) keeps only that name: the keys inside the value are still camelized. A wrapped prop name also arrives as written (tr_data, not trData), which does not match the camelCase key of the page declaration.

Validate rendered props (dev and test)

# config/dev.exs and config/test.exs
config :typelizer, validate_inertia_props: true
# lib/my_app_web/router.ex
pipeline :browser do
  # …
  plug Inertia.Plug
  plug Typelizer.InertiaPage.ValidateProps
end

When a page renders, the plug compares the top-level keys of the rendered props with the declaration: the page props, the shared props, errors and flash, after the key transform.

  • A declared prop is missing, and it is not {:optional, _} → error.
  • A deferred prop is declared without {:optional, _} → error, because the first load does not send it.
  • A prop is present but not declared → error.
  • The component has no page declaration → error. Set config :typelizer, undeclared_inertia_pages: :ignore to allow it while you adopt Typelizer.

On a partial reload, the plug checks only for props that are not declared.

The error is Typelizer.InertiaPage.PropsError. The plug raises it before the response is sent, so a controller test fails with a clear message:

** (Typelizer.InertiaPage.PropsError) Inertia page "tasks/index" does not match its declaration in MyAppWeb.TaskController:
  missing: nextCursor
  not declared: next_cursor
Hint: the keys are not camelCase. Set `config :inertia, camelize_props: true` or call camelize_props/1.

With validate_inertia_props: false (the default, and what you want in production), the plug does nothing.

Check the values too

By default the plug checks only the keys. Set :values to check each rendered value against its declared type as well:

# config/dev.exs and config/test.exs
config :typelizer, validate_inertia_props: :values
** (Typelizer.InertiaPage.PropsError) Inertia page "tasks/index" does not match its declaration in MyAppWeb.TaskController:
  tasks[3].status: expected "todo" | "doing" | "done", got "archived"
  tasks[0]: missing key dueOn (got due_on)
  nextCursor: expected string | null, got 42
  • Values are checked as they will be encoded to JSON: an atom counts as a string, a Date or DateTime as an ISO-8601 string, a Decimal as a string.
  • Serializer props are checked field by field, nested serializers included: the keys (including optional: and if: keys, which may be left out), their nullability and their types. A raw struct where a serializer map is expected is an error: serialize it first.
  • {:object, …}, lists, records, enums, unions and the envelope types are checked. :any, :unknown, {:ts, …} and {:intersection, …} are not.
  • Nullability that comes from the database (repo:) is not known at runtime, so those fields accept nil.
  • Only the props that are present are checked (deferred, partial and once props included).
  • errors and flash, when you do not declare them in shared, are only checked to be maps: error bags nest one level deeper.
  • Any other value of validate_inertia_props than false, true or :values raises an ArgumentError, so a typo does not turn the check off.
  • The message lists at most 20 problems per page.

The check walks every value on every render, so keep it to dev and test. validate_inertia_props: true keeps the key-only check.