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
endpage/2 takes the component name (the same string you pass to
render_inertia/2) and these options:
| Option | Meaning |
|---|---|
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 withTypelizer.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.genfail.
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
endThe 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: trueOr 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
:camelkey transform does not change: its keys are already camelCase. - A serializer with
key_transform: :snakearrives camelCased, so its TypeScript interface (snake_case) is wrong. Withcamelize_props, keep the:camelkey 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/1keeps 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
endWhen 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
pagedeclaration → error. Setconfig :typelizer, undeclared_inertia_pages: :ignoreto 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
DateorDateTimeas an ISO-8601 string, aDecimalas a string. - Serializer props are checked field by field, nested serializers included: the
keys (including
optional:andif: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 acceptnil. - Only the props that are present are checked (deferred, partial and once props included).
errorsandflash, when you do not declare them inshared, are only checked to be maps: error bags nest one level deeper.- Any other value of
validate_inertia_propsthanfalse,trueor:valuesraises anArgumentError, 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.