Typed controllers are a simple abstraction that generates ordinary Phoenix controllers from a declarative DSL. The same DSL also enables generating TypeScript path helpers and typed fetch functions, giving you end-to-end type safety for controller-style routes.

When to Use Typed Controllers

Typed controllers are especially useful for server-rendered pages or endpoints, for example with regards to cookie session management, and anything else where an rpc action isn't a natural fit.

Quick Start

1. Define a Typed Controller

Create a module that uses AshTypescript.TypedController and define your routes:

defmodule MyApp.Session do
  use AshTypescript.TypedController

  typed_controller do
    module_name MyAppWeb.SessionController

    route :auth do
      method :get
      run fn conn, _params ->
        render(conn, "auth.html")
      end
    end

    route :login do
      method :post
      argument :code, :string, allow_nil?: false
      argument :remember_me, :boolean
      run fn conn, %{magic_link_token: token, remember_me: remember_me} ->
        case MyApp.Auth.get_user_from_magic_link_token(token) do
          {:ok, user} ->
            conn
            |> put_session(:user_id, user.id)
            |> redirect(to: "/dashboard")

          {:error, _} ->
            conn
            |> put_flash(:error, "Invalid token")
            |> redirect(to: "/auth")
        end
      end
    end

    route :logout do
      method :get
      run fn conn, _params ->
        conn
        |> clear_session()
        |> redirect(to: "/auth")
      end
    end
  end
end

2. Add Routes to Your Phoenix Router

The module_name in the DSL determines the generated Phoenix controller module. Wire it into your router like any other controller:

defmodule MyAppWeb.Router do
  use Phoenix.Router

  scope "/auth" do
    pipe_through [:browser]

    get "/", SessionController, :auth
    post "/login", SessionController, :login
    get "/logout", SessionController, :logout
  end
end

3. Configure Code Generation

Add the typed controller configuration to your config/config.exs:

config :ash_typescript,
  typed_controllers: [MyApp.Session],
  router: MyAppWeb.Router,
  routes_output_file: "assets/js/routes.ts"

4. Generate TypeScript

Run the code generator:

mix ash.codegen
# or
mix ash_typescript.codegen

This generates a TypeScript file with path helpers and typed fetch functions:

// assets/js/routes.ts (auto-generated)

export function authPath(): string {
  return "/auth";
}

export function loginPath(): string {
  return "/auth/login";
}

export type LoginInput = {
  magicLinkToken: string;
  rememberMe?: boolean;
};

export async function login(
  input: LoginInput,
  config?: { headers?: Record<string, string> }
): Promise<Response> {
  return fetch("/auth/login", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      ...config?.headers,
    },
    body: JSON.stringify(input),
  });
}

export function logoutPath(): string {
  return "/auth/logout";
}

5. Use in Your Frontend

import { authPath, login, logout } from "./routes";

// GET routes generate path helpers
const authUrl = authPath(); // "/auth"

// POST/PATCH/PUT/DELETE routes generate typed async functions
const response = await login({
  magicLinkToken: "my-token",
  rememberMe: true,
});

const logoutUrl = logout();

DSL Reference

typed_controller Section

OptionTypeRequiredDescription
module_nameatomYesThe Phoenix controller module to generate (e.g., MyAppWeb.SessionController)

route Options

OptionTypeRequiredDefaultDescription
nameatomYesController action name (positional arg)
methodatomYesHTTP method: :get, :post, :patch, :put, :delete
runfn/2 or moduleYesHandler function or module
descriptionstringNoJSDoc description in generated TypeScript
deprecatedboolean or stringNoMark as deprecated in TypeScript (true for default message, string for custom)

argument Options

OptionTypeRequiredDefaultDescription
nameatomYesArgument name (positional arg)
typeatom or {atom, keyword}YesAsh type (:string, :boolean, :integer, etc.) or {type, constraints} tuple
constraintskeywordNo[]Type constraints
allow_nil?booleanNotrueIf false, argument is required
defaultanyNoDefault value

Route Handlers

Inline Functions

The simplest approach — define the handler directly in the DSL:

route :auth do
  method :get
  run fn conn, _params ->
    render(conn, "auth.html")
  end
end

Handler Modules

For more complex logic, implement the AshTypescript.TypedController.Route behaviour:

defmodule MyApp.Handlers.Login do
  @behaviour AshTypescript.TypedController.Route

  @impl true
  def run(conn, %{magic_link_token: token}) do
    case MyApp.Auth.get_user_from_magic_link_token(token) do
      {:ok, user} ->
        conn
        |> Plug.Conn.put_session(:user_id, user.id)
        |> Phoenix.Controller.redirect(to: "/dashboard")

      {:error, _} ->
        conn
        |> Phoenix.Controller.put_flash(:error, "Invalid token")
        |> Phoenix.Controller.redirect(to: "/auth")
    end
  end
end

Then reference it in the DSL:

route :login do
  method :post
  argument :magic_link_token, :string, allow_nil?: false
  run MyApp.Handlers.Login
end

Handlers must return a %Plug.Conn{} struct. Returning anything else results in a 500 error.

Request Handling

When a request hits a typed controller route, AshTypescript automatically:

  1. Strips Phoenix internal params (_format, action, controller, params starting with _)
  2. Normalizes camelCase param keys to snake_case
  3. Extracts only declared arguments (undeclared params are dropped)
  4. Validates required arguments (allow_nil?: false) — missing args produce 422 errors
  5. Casts values using Ash.Type.cast_input/3 — invalid values produce 422 errors
  6. Dispatches to the handler with atom-keyed params

Error Responses

422 Unprocessable Entity (validation errors):

{
  "errors": [
    { "field": "code", "message": "is required" },
    { "field": "count", "message": "is invalid" }
  ]
}

All validation errors are collected in a single pass, so the client receives every issue at once.

500 Internal Server Error (handler doesn't return %Plug.Conn{}):

{
  "errors": [
    { "message": "Route handler must return %Plug.Conn{}, got: {:ok, \"result\"}" }
  ]
}

Generated TypeScript

GET Routes — Path Helpers

GET routes generate synchronous path helper functions:

route :auth do
  method :get
  run fn conn, _params -> render(conn, "auth.html") end
end
export function authPath(): string {
  return "/auth";
}

GET Routes with Arguments — Query Parameters

Arguments on GET routes become query parameters:

route :search do
  method :get
  argument :q, :string, allow_nil?: false
  argument :page, :integer
  run fn conn, params -> render(conn, "search.html", params) end
end
export function searchPath(query: { q: string; page?: number }): string {
  const base = "/search";
  const searchParams = new URLSearchParams();
  searchParams.set("q", String(query.q));
  if (query?.page !== undefined) searchParams.set("page", String(query.page));
  const qs = searchParams.toString();
  return qs ? `${base}?${qs}` : base;
}

Mutation Routes — Typed Fetch Functions

POST, PATCH, PUT, and DELETE routes generate async fetch functions with typed inputs:

route :login do
  method :post
  argument :code, :string, allow_nil?: false
  argument :remember_me, :boolean
  run fn conn, params -> handle_login(conn, params) end
end
export type LoginInput = {
  code: string;
  rememberMe?: boolean;
};

export async function login(
  input: LoginInput,
  config?: { headers?: Record<string, string> }
): Promise<Response> {
  return fetch("/auth/login", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      ...config?.headers,
    },
    body: JSON.stringify(input),
  });
}

Routes with Path Parameters

When a router path includes parameters (e.g., /organizations/:org_slug), they become a separate path parameter in the generated TypeScript. Every path parameter must have a matching argument in the route definition.

For GET routes, path params are interpolated into the path helper:

route :settings do
  method :get
  argument :org_slug, :string
  run fn conn, _params -> render(conn, "settings.html") end
end

Router:

scope "/organizations/:org_slug" do
  get "/settings", OrganizationController, :settings
end

Generated TypeScript (default :object style):

export function settingsPath(path: { orgSlug: string }): string {
  return `/organizations/${path.orgSlug}/settings`;
}

When a GET route has both path params and additional arguments, the path params are placed in a path object and the remaining arguments become query parameters:

route :members do
  method :get
  argument :org_slug, :string
  argument :role, :string
  argument :page, :integer
  run fn conn, params -> render(conn, "members.html", params) end
end

Router:

scope "/organizations/:org_slug" do
  get "/members", OrganizationController, :members
end

Generated TypeScript:

export function membersPath(
  path: { orgSlug: string },
  query?: { role?: string; page?: number }
): string {
  const base = `/organizations/${path.orgSlug}/members`;
  const searchParams = new URLSearchParams();
  if (query?.role !== undefined) searchParams.set("role", String(query.role));
  if (query?.page !== undefined) searchParams.set("page", String(query.page));
  const qs = searchParams.toString();
  return qs ? `${base}?${qs}` : base;
}

For mutation routes, path params are separated from the request body input:

route :update_provider do
  method :patch
  argument :provider, :string
  argument :enabled, :boolean, allow_nil?: false
  argument :display_name, :string
  run fn conn, params -> handle_update(conn, params) end
end

Router:

patch "/providers/:provider", SessionController, :update_provider

Generated TypeScript:

export type UpdateProviderInput = {
  enabled: boolean;
  displayName?: string;
};

export async function updateProvider(
  path: { provider: string },
  input: UpdateProviderInput,
  config?: { headers?: Record<string, string> }
): Promise<Response> {
  return fetch(`/auth/providers/${path.provider}`, {
    method: "PATCH",
    headers: {
      "Content-Type": "application/json",
      ...config?.headers,
    },
    body: JSON.stringify(input),
  });
}

Path parameters are excluded from the input type and placed in the path parameter.

Function Parameter Order

Generated functions follow this parameter order:

  1. path (if route has path params): path: { param: Type }
  2. input (if route has non-path arguments): input: InputType
  3. config (always optional): config?: { headers?: Record<string, string> }

Multi-Mount Routes

When a controller is mounted at multiple paths, AshTypescript uses the Phoenix as: option to disambiguate:

scope "/admin", as: :admin do
  get "/auth", SessionController, :auth
  post "/login", SessionController, :login
end

scope "/app", as: :app do
  get "/auth", SessionController, :auth
  post "/login", SessionController, :login
end

Generated TypeScript uses scope prefixes:

// Admin scope
export function adminAuthPath(): string { return "/admin/auth"; }
export async function adminLogin(input: AdminLoginInput, config?): Promise<Response> { ... }

// App scope
export function appAuthPath(): string { return "/app/auth"; }
export async function appLogin(input: AppLoginInput, config?): Promise<Response> { ... }

If routes are mounted at multiple paths without unique as: options, codegen will raise an error with instructions to add them.

Paths-Only Mode

If you only need path helpers (no fetch functions), use the :paths_only mode:

config :ash_typescript,
  typed_controller_mode: :paths_only

This generates only path helpers for all routes, skipping input types and async functions. Useful when you handle mutations via a different client library or directly with fetch.

Configuration Reference

OptionTypeDefaultDescription
typed_controllerslist of modules[]TypedController modules to generate route helpers for
routermodulenilPhoenix router for path introspection
routes_output_filestringnilOutput file path (when nil, route generation is skipped)
typed_controller_mode:full or :paths_only:fullGeneration mode
typed_controller_path_params_style:object or :args:objectPath params style (see below)

All three of typed_controllers, router, and routes_output_file must be configured for route generation to run.

Path Params Style

Controls how path parameters are represented in all generated TypeScript functions (GET path helpers, mutation path helpers, and mutation action functions):

  • :object (default) — path params are wrapped in a path: { ... } object:

    settingsPath(path: { orgSlug: string })
    updateProvider(path: { provider: string }, input: UpdateProviderInput, config?)
  • :args — path params are flat positional arguments:

    settingsPath(orgSlug: string)
    updateProvider(provider: string, input: UpdateProviderInput, config?)

Compile-Time Validation

AshTypescript validates typed controllers at compile time:

  • Unique route names — no duplicates within a module
  • Handlers present — every route must have a run handler
  • Valid argument types — all types must be valid Ash types
  • Valid names for TypeScript — route and argument names must not contain _1-style patterns or ? characters

Path parameters are also validated at codegen time: every :param in the router path must have a matching DSL argument.

Next Steps