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:

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

```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)

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

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

| Option | Type | Required | Description |
|--------|------|----------|-------------|
| `module_name` | atom | Yes | The Phoenix controller module to generate (e.g., `MyAppWeb.SessionController`) |

### `route` Options

| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| name | atom | Yes | — | Controller action name (positional arg) |
| `method` | atom | Yes | — | HTTP method: `:get`, `:post`, `:patch`, `:put`, `:delete` |
| `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) |

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

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

```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
route :auth do
  method :get
  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
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
```

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

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

```elixir
route :settings do
  method :get
  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
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:
```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
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:
```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?: { 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:

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

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

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

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

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