Packages
ash_typescript
0.17.0
0.17.3
0.17.2
0.17.1
0.17.0
0.16.0
0.15.3
0.15.2
retired
0.15.1
retired
0.15.0
0.14.4
0.14.3
0.14.2
0.14.1
0.14.0
retired
0.13.2
0.13.1
0.13.0
0.12.1
0.12.0
0.11.6
0.11.5
0.11.4
0.11.3
0.11.2
0.11.1
0.11.0
0.10.2
0.10.1
0.10.0
0.9.1
0.9.0
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.1
0.7.0
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.0
0.4.0
0.3.3
0.3.2
0.3.1
0.2.0
0.1.2
0.1.0
Generate type-safe TypeScript clients directly from your Ash resources and actions, ensuring end-to-end type safety between your backend and frontend.
Current section
Files
Jump to
Current section
Files
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