Typelizer reads your Phoenix router and writes a TypeScript module with one typed helper per route. The frontend never hardcodes a path, and a renamed route breaks the TypeScript build instead of a user's click.
# lib/my_app_web/router.ex
scope "/", MyAppWeb do
pipe_through :browser
get "/", PageController, :home
resources "/tasks", TaskController do
resources "/comments", CommentController, only: [:index, :create]
end
endimport { routes, setRoutesBaseUrl } from "@/generated/routes";
routes.page.home(); // { url: "/", method: "get" }
routes.task.index(); // { url: "/tasks", method: "get" }
routes.task.show(42); // { url: "/tasks/42", method: "get" }
routes.task.show({ id: 42 }); // the same
routes.task.update(42); // { url: "/tasks/42", method: "patch" }
routes.task.delete(42); // { url: "/tasks/42", method: "delete" }
routes.taskComment.create({ taskId: 42 }); // { url: "/tasks/42/comments", method: "post" }
routes.task.index({ query: { page: 2, tags: ["a", "b"] } });
// { url: "/tasks?page=2&tags[]=a&tags[]=b", method: "get" }
routes.task.show(42, { anchor: "notes" });
// { url: "/tasks/42#notes", method: "get" }
setRoutesBaseUrl("https://app.example.com");
routes.task.show(42).url; // "https://app.example.com/tasks/42"Each helper returns a RouteDefinition: { url: string; method: M }, where
M is the literal HTTP method. Pass it to your HTTP client or to Inertia:
import { Link, router } from "@inertiajs/react";
<Link href={routes.task.show(task.id).url}>{task.title}</Link>;
router.visit(routes.task.update(task.id).url, { method: "patch", data });Setup
The router is found automatically when your app has exactly one module with
__routes__/0. Set it when you have several:
config :typelizer, router: MyAppWeb.RouterSet router: false to skip route helpers.
Rules
- Group name: the Phoenix helper name of the route, camelCased:
task,taskComment,apiV1Task. Change it with the router'sas:option. A route without a helper name uses its controller:MyAppWeb.Admin.UserController→adminUser. - Action name: the controller action, camelCased:
index,show,new,edit,create,update,delete, or any custom action. A LiveView route uses its live action, orindexwhen it has none. PUTandPATCHfor the same action become one helper withmethod: "patch".- Two routes with the same group and action make
mix typelizer.genfail with a message that names both routes. Setas:on one of them or exclude one. - Only
GET,POST,PUT,PATCHandDELETEroutes are generated.forwardroutes are skipped.
becomesPath params are typed `string number . A glob param (*path`) takes`string string[] . The param keys follow the key transform (:task_id`taskIdwith:camel); at runtime both spellings work.- A route with one path param accepts the value directly:
show(42). A route with several takes an object:show({ taskId: 1, id: 2 }). - The last argument is optional:
{ query?: Record<string, unknown>; anchor?: string }. A route without path params takes only this argument. - Query strings follow the Plug conventions: arrays become
key[]=v, nested objects becomekey[sub]=v, and arrays of objects becomesort[0][field]=price&sort[1][field]=name(the indexed map that Phoenix forms send, whichcast_assocaccepts).Datevalues become ISO-8601 strings, andnullorundefinedvalues are skipped. The anchor is URL-encoded.
Filters
config :typelizer,
routes: [
include: ["/api", "/app"],
exclude: ["/dev", ~r{^/admin/internal}]
]An entry is a path prefix (a string) or a Regex. A prefix matches whole path
segments: "/api" matches /api and /api/tasks, not /apiary.
include: [] (the default) keeps every route. exclude wins over include.
The default is exclude: ["/dev"]: Phoenix puts its development-only routes
(LiveDashboard, the mailbox preview) under /dev, and they exist only in the dev
environment. Keep them out, or mix typelizer.check gives different results in
dev and in CI. When you set exclude, it replaces the default.
Generated files
The default directory is assets/js/generated/routes:
runtime.ts: theMethod,RouteDefinition<M>andRouteOptionstypes,setRoutesBaseUrl()andbuildUrl().<group>.ts: one file per group, for exampletask.tsexportstask.index.ts: re-exports each group and the runtime API, and exportsroutes, one object with every group.
// assets/js/generated/routes/task.ts
// Generated by typelizer — do not edit.
import { buildUrl, type RouteDefinition, type RouteOptions } from "./runtime";
export const task = {
/** POST /tasks */
create: (options?: RouteOptions): RouteDefinition<"post"> => ({
url: buildUrl("/tasks", {}, options),
method: "post",
}),
/** DELETE /tasks/:id */
delete: (
params: { id: string | number } | string | number,
options?: RouteOptions,
): RouteDefinition<"delete"> => ({
url: buildUrl("/tasks/:id", params, options),
method: "delete",
}),
// edit, index, new, show, update
} as const;// assets/js/generated/routes/index.ts
// Generated by typelizer — do not edit.
import { page } from "./page";
import { task } from "./task";
import { taskComment } from "./taskComment";
export { page, task, taskComment };
export { buildUrl, setRoutesBaseUrl } from "./runtime";
export type { Method, RouteDefinition, RouteOptions } from "./runtime";
export const routes = {
page,
task,
taskComment,
} as const;Groups and actions are sorted by name, so the output does not change when you reorder the router.