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
end
import { 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.Router

Set 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's as: 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, or index when it has none.
  • PUT and PATCH for the same action become one helper with method: "patch".
  • Two routes with the same group and action make mix typelizer.gen fail with a message that names both routes. Set as: on one of them or exclude one.
  • Only GET, POST, PUT, PATCH and DELETE routes are generated. forward routes are skipped.
  • Path params are typed `stringnumber. A glob param (*path`) takes
    `stringstring[]. The param keys follow the key transform (:task_id`
    becomes taskId with :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 become key[sub]=v, and arrays of objects become sort[0][field]=price&sort[1][field]=name (the indexed map that Phoenix forms send, which cast_assoc accepts). Date values become ISO-8601 strings, and null or undefined values 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: the Method, RouteDefinition<M> and RouteOptions types, setRoutesBaseUrl() and buildUrl().
  • <group>.ts: one file per group, for example task.ts exports task.
  • index.ts: re-exports each group and the runtime API, and exports routes, 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.