documentation/guides/typed-controllers.md

<!--
SPDX-FileCopyrightText: 2025 Torkild G. Kjevik
SPDX-FileCopyrightText: 2025 ash_typescript contributors <https://github.com/ash-project/ash_typescript/graphs/contributors>

SPDX-License-Identifier: MIT
-->

# Typed Controllers

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. The preferred syntax uses HTTP verb shortcuts (`get`, `post`, `patch`, `put`, `delete`):

```elixir
defmodule MyApp.Session do
  use AshTypescript.TypedController

  typed_controller do
    module_name MyAppWeb.SessionController

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

    post :login do
      argument :magic_link_token, :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

    get :logout do
      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:

```elixir
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`:

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

### 4. Generate TypeScript

Run the code generator:

```bash
mix ash.codegen
# or
mix ash_typescript.codegen
```

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

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

/**
 * Configuration options for typed controller requests
 */
export interface TypedControllerConfig {
  headers?: Record<string, string>;
  fetchOptions?: RequestInit;
  customFetch?: (
    input: RequestInfo | URL,
    init?: RequestInit,
  ) => Promise<Response>;
}

export async function executeTypedControllerRequest(
  url: string,
  method: string,
  actionName: string,
  body: string | undefined,
  config?: TypedControllerConfig,
): Promise<Response> {
  const processedConfig = config || {};
  const headers: Record<string, string> = {
    "Content-Type": "application/json",
    ...processedConfig.headers,
  };
  const fetchFunction = processedConfig.customFetch || fetch;
  const fetchInit: RequestInit = {
    ...processedConfig.fetchOptions,
    method,
    headers,
    ...(body !== undefined ? { body } : {}),
  };
  const response = await fetchFunction(url, fetchInit);
  return response;
}

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?: TypedControllerConfig,
): Promise<Response> {
  return executeTypedControllerRequest(
    "/auth/login", "POST", "login", JSON.stringify(input), config,
  );
}

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

### 5. Use in Your Frontend

```typescript
import { authPath, login, logoutPath } 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 },
  { headers: { "X-CSRF-Token": csrfToken } },
);

const logoutUrl = logoutPath(); // "/auth/logout"
```

## DSL Reference

### `typed_controller` Section

| Option | Type | Required | Description |
|--------|------|----------|-------------|
| `module_name` | atom | Yes | The Phoenix controller module to generate (e.g., `MyAppWeb.SessionController`) |
| `namespace` | string | No | Default namespace for all routes in this controller. Can be overridden per-route. |

### Three Route Syntaxes

The DSL supports three ways to define routes:

**Verb shortcuts (preferred)** — the HTTP method is the entity name:
```elixir
get :auth do
  run fn conn, _params -> render(conn, "auth.html") end
end

post :login do
  run fn conn, _params -> handle_login(conn) end
  argument :code, :string, allow_nil?: false
end
```

**Positional method arg** — method as second argument to `route`:
```elixir
route :logout, :post do
  run fn conn, _params -> handle_logout(conn) end
end
```

**Default method** — `route` without method defaults to `:get`:
```elixir
route :home do
  run fn conn, _params -> render(conn, "home.html") end
end
```

### Route Options

| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| name | atom | Yes | — | Controller action name (positional arg) |
| `method` | atom | No | `:get` | HTTP method: `:get`, `:post`, `:patch`, `:put`, `:delete`. Implicit with verb shortcuts. |
| `run` | fn/2 or module | Yes | — | Handler function or module |
| `description` | string | No | — | JSDoc description in generated TypeScript |
| `deprecated` | boolean or string | No | — | Mark as deprecated in TypeScript (`true` for default message, string for custom) |
| `see` | list of atoms | No | `[]` | Related route names for JSDoc `@see` tags |
| `namespace` | string | No | — | Namespace for this route (overrides controller-level namespace) |
| `zod_schema_name` | string | No | — | Override generated Zod schema name (avoids collisions with RPC) |

### `argument` Options

| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| name | atom | Yes | — | Argument name (positional arg) |
| type | atom or `{atom, keyword}` | Yes | — | Ash type (`:string`, `:boolean`, `:integer`, etc.) or `{type, constraints}` tuple |
| `constraints` | keyword | No | `[]` | Type constraints |
| `allow_nil?` | boolean | No | `true` | If `false`, argument is required |
| `default` | any | No | — | Default value |

## Route Handlers

### Inline Functions

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

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

### Handler Modules

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

```elixir
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:

```elixir
post :login do
  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):

```json
{
  "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{}`):

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

## Generated TypeScript

### GET Routes — Path Helpers

GET routes generate synchronous path helper functions:

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

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

### GET Routes with Arguments — Query Parameters

Arguments on GET routes become query parameters:

```elixir
get :search do
  argument :q, :string, allow_nil?: false
  argument :page, :integer
  run fn conn, params -> render(conn, "search.html", params) end
end
```

```typescript
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:

```elixir
post :login do
  argument :code, :string, allow_nil?: false
  argument :remember_me, :boolean
  run fn conn, params -> handle_login(conn, params) end
end
```

```typescript
export type LoginInput = {
  code: string;
  rememberMe?: boolean;
};

export async function login(
  input: LoginInput,
  config?: TypedControllerConfig,
): Promise<Response> {
  return executeTypedControllerRequest(
    "/auth/login", "POST", "login", JSON.stringify(input), config,
  );
}
```

The `TypedControllerConfig` interface and `executeTypedControllerRequest` helper are generated once at the top of the file and shared by all mutation functions. See [Lifecycle Hooks](#lifecycle-hooks) for how hooks integrate with this helper.

### 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:

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

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

Generated TypeScript (default `:object` style):
```typescript
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:

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

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

Generated TypeScript:
```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:

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

Router:
```elixir
patch "/providers/:provider", SessionController, :update_provider
```

Generated TypeScript:
```typescript
export type UpdateProviderInput = {
  enabled: boolean;
  displayName?: string;
};

export async function updateProvider(
  path: { provider: string },
  input: UpdateProviderInput,
  config?: TypedControllerConfig,
): Promise<Response> {
  return executeTypedControllerRequest(
    `/auth/providers/${path.provider}`, "PATCH", "updateProvider",
    JSON.stringify(input), config,
  );
}
```

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?: TypedControllerConfig`

## Multi-Mount Routes

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

```elixir
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:

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

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

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

## Base Path

When your frontend is deployed separately from the backend (e.g., a standalone SPA calling an API on a different domain), you can configure a base path that is prepended to all generated route URLs:

```elixir
config :ash_typescript,
  typed_controller_base_path: "https://api.example.com"
```

Generated TypeScript:

```typescript
const _basePath = "https://api.example.com";

export function authPath(): string {
  return `${_basePath}/auth`;
}

export async function login(
  input: LoginInput,
  config?: TypedControllerConfig,
): Promise<Response> {
  return executeTypedControllerRequest(
    `${_basePath}/auth/login`, "POST", "login", JSON.stringify(input), config,
  );
}
```

### Runtime Expressions

For dynamic base paths (e.g., from environment variables or app config), use `{:runtime_expr, "..."}`:

```elixir
config :ash_typescript,
  typed_controller_base_path: {:runtime_expr, "AppConfig.getBasePath()"}
```

This embeds the expression directly in the generated code:

```typescript
const _basePath = AppConfig.getBasePath();

export function authPath(): string {
  return `${_basePath}/auth`;
}
```

This follows the same `{:runtime_expr, "..."}` pattern used by [Dynamic RPC Endpoints](../reference/configuration.md#dynamic-rpc-endpoints).

When `typed_controller_base_path` is not set or is `""` (the default), no `_basePath` variable is generated and paths remain as plain strings (e.g., `"/auth"`).

## Paths-Only Mode

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

```elixir
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`.

## Namespaces

Typed controllers support namespaces for organizing generated route helpers into separate files — the same concept as [RPC namespaces](../features/developer-experience.md#namespaces).

### Configuration

Set a default namespace at the controller level, and optionally override per-route:

```elixir
defmodule MyApp.Session do
  use AshTypescript.TypedController

  typed_controller do
    module_name MyAppWeb.SessionController
    namespace "auth"  # Default namespace for all routes

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

    post :login do
      run fn conn, _params -> handle_login(conn) end
      argument :code, :string, allow_nil?: false
    end

    # This route goes into a different namespace
    get :profile do
      namespace "account"  # Overrides the controller-level "auth"
      run fn conn, _params -> render(conn, "profile.html") end
    end
  end
end
```

### Precedence

Route-level namespace overrides controller-level. Routes without any namespace go into the main routes file.

### Generated Output

With the example above, code generation produces:
- `routes.ts` — imports and re-exports from namespace files
- `namespace/auth.ts` — `authPath`, `login`, `LoginInput`, etc.
- `namespace/account.ts` — `profilePath`

## JSDoc `@see` Tags

Use the `see` option to add cross-references between related routes:

```elixir
post :login do
  see [:auth, :logout]
  argument :code, :string, allow_nil?: false
  run fn conn, params -> handle_login(conn, params) end
end
```

Generated TypeScript includes `@see` tags in the JSDoc comments:

```typescript
/**
 * POST /auth/login
 * @see auth
 * @see logout
 */
export async function login(input: LoginInput, config?: TypedControllerConfig): Promise<Response> {
  ...
}
```

The `@see` tags reference route names using their formatted output names (camelCase by default).

## Lifecycle Hooks

Lifecycle hooks let you intercept typed controller requests to add custom behavior like authentication headers, logging, or telemetry.

### Configuration

```elixir
config :ash_typescript,
  typed_controller_before_request_hook: "RouteHooks.beforeRequest",
  typed_controller_after_request_hook: "RouteHooks.afterRequest",
  typed_controller_hook_context_type: "RouteHooks.RouteHookContext",
  typed_controller_import_into_generated: [
    %{import_name: "RouteHooks", file: "./routeHooks"}
  ]
```

### Hook Signatures

**beforeRequest** — called before the HTTP request, can modify config:

```typescript
export async function beforeRequest(
  actionName: string,
  config: TypedControllerConfig,
): Promise<TypedControllerConfig> {
  // Add auth headers, set credentials, start timing, etc.
  return {
    ...config,
    fetchOptions: { ...config.fetchOptions, credentials: "include" },
  };
}
```

**afterRequest** — called after the HTTP request completes:

```typescript
export async function afterRequest(
  actionName: string,
  response: Response,
  config: TypedControllerConfig,
): Promise<void> {
  // Log, measure timing, report errors, etc.
  console.log(`[${actionName}] status: ${response.status}`);
}
```

### Hook Context

When hooks are enabled, the `TypedControllerConfig` interface includes an optional `hookCtx` field typed to your configured context type. This lets you pass per-request metadata (like timing flags or custom headers) through the request lifecycle:

```typescript
await login(
  { code: "abc123" },
  {
    hookCtx: { enableLogging: true, enableTiming: true },
  },
);
```

## Custom Imports

Use `typed_controller_import_into_generated` to add custom TypeScript imports to the generated routes file. This is typically used alongside lifecycle hooks:

```elixir
config :ash_typescript,
  typed_controller_import_into_generated: [
    %{import_name: "RouteHooks", file: "./routeHooks"},
    %{import_name: "Analytics", file: "./analytics"}
  ]
```

Generated output:

```typescript
import * as RouteHooks from "./routeHooks";
import * as Analytics from "./analytics";
```

## Zod Schema Generation

When `generate_zod_schemas: true` is configured, mutation routes also generate Zod validation schemas alongside their input types:

```typescript
export type LoginInput = {
  code: string;
  rememberMe?: boolean;
};

export const loginZodSchema = z.object({
  code: z.string().min(1),
  rememberMe: z.boolean().optional(),
});
```

The schemas use the same `zod_import_path` and `zod_schema_suffix` settings as RPC Zod schemas. The `z` import is automatically added to the generated routes file.

## Error Handling

### Error Handler

Configure a custom error handler to transform validation errors before they are sent to the client:

```elixir
config :ash_typescript,
  typed_controller_error_handler: {MyApp.ErrorHandler, :handle, []}
```

The handler is called for each error (both 422 validation errors and 500 server errors). It receives the error map and a context map containing the route name and source module:

```elixir
defmodule MyApp.ErrorHandler do
  def handle(error, %{route: route_name, source_module: module}) do
    # Transform, log, or filter errors
    # Return nil to suppress the error, or a modified error map
    Map.put(error, :code, "VALIDATION_ERROR")
  end
end
```

You can also pass a module implementing `handle_error/2`:

```elixir
config :ash_typescript,
  typed_controller_error_handler: MyApp.ErrorHandler
```

### Show Raised Errors

By default, unhandled exceptions in route handlers return a generic "Internal server error" message. For development, you can expose the actual exception message:

```elixir
# config/dev.exs
config :ash_typescript,
  typed_controller_show_raised_errors: true
```

When enabled, 500 responses include the real exception message instead of the generic one. **Do not enable in production.**

## Configuration Reference

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `typed_controllers` | list of modules | `[]` | TypedController modules to generate route helpers for |
| `router` | module | `nil` | Phoenix router for path introspection |
| `routes_output_file` | string | `nil` | Output file path (when `nil`, route generation is skipped) |
| `typed_controller_mode` | `:full` or `:paths_only` | `:full` | Generation mode |
| `typed_controller_path_params_style` | `:object` or `:args` | `:object` | Path params style (see below) |
| `typed_controller_base_path` | string or `{:runtime_expr, string}` | `""` | Base URL prefix for all generated route URLs |
| `typed_controller_before_request_hook` | string or nil | `nil` | Function called before requests |
| `typed_controller_after_request_hook` | string or nil | `nil` | Function called after requests |
| `typed_controller_hook_context_type` | string | `"Record<string, any>"` | TypeScript type for hook context |
| `typed_controller_import_into_generated` | list of maps | `[]` | Custom imports for generated file |
| `typed_controller_error_handler` | MFA tuple, module, or nil | `nil` | Custom error transformation handler |
| `typed_controller_show_raised_errors` | boolean | `false` | Show exception messages in 500 responses |
| `enable_controller_namespace_files` | boolean | `false` | Generate separate files for namespaced routes |
| `controller_namespace_output_dir` | string or nil | `nil` | Directory for namespace files (defaults to `routes_output_file` dir) |

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

Route helpers are part of AshTypescript's multi-file output architecture — shared types and Zod schemas are generated into separate files that both RPC and controller code import from. See [Configuration Reference — Multi-File Output](../reference/configuration.md#multi-file-output) for the full file layout.

### 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:
  ```typescript
  settingsPath(path: { orgSlug: string })
  updateProvider(path: { provider: string }, input: UpdateProviderInput, config?)
  ```

- **`:args`** — path params are flat positional arguments:
  ```typescript
  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
- **Always-present path params** must have `allow_nil?: false` — if a path parameter exists at every mount of a route, it is always provided by the router and can never be nil
- **Sometimes-present path params** must have `allow_nil?: true` — if a route is mounted at multiple paths and a parameter only appears at some mounts, it will be nil at the others

```elixir
# ✅ Correct — :provider is always a path param, so allow_nil?: false
get :provider_page do
  argument :provider, :string, allow_nil?: false
  run fn conn, _params -> render(conn, "provider.html") end
end

# ✅ Correct — :id is only a path param at /admin/pages/:id, nil at /app/pages
get :page do
  argument :id, :string  # allow_nil?: true (default) is correct here
  run fn conn, _params -> render(conn, "page.html") end
end
```

## Next Steps

- [Configuration Reference](../reference/configuration.md) - Full configuration options
- [Mix Tasks Reference](../reference/mix-tasks.md) - Code generation commands
- [Troubleshooting](../reference/troubleshooting.md) - Common issues