Packages
ash_typescript
0.18.0
0.18.2
0.18.1
0.18.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
ash_typescript
usage-rules.md
usage-rules.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
-->
# AshTypescript Usage Rules
## Quick Reference
**Critical**: Declare a manifest module + `config :ash_typescript, manifest: ...`, add `AshTypescript.Rpc` extension to domain, run `mix ash_typescript.codegen`
**Authentication**: Use `buildCSRFHeaders()` for Phoenix CSRF protection
**Controller Routes**: Use `AshTypescript.TypedController` for controller-style actions with `conn` access
**Typed Channels**: Use `AshTypescript.TypedChannel` for typed PubSub event subscriptions
**Validation**: Always verify generated TypeScript compiles
## Essential Syntax Table
| Pattern | Syntax | Example |
|---------|--------|---------|
| **Domain Setup** | `use Ash.Domain, extensions: [AshTypescript.Rpc]` | Required extension |
| **RPC Action** | `rpc_action :name, :action_type` | `rpc_action :list_todos, :read` |
| **Basic Call** | `functionName({ fields: [...] })` | `listTodos({ fields: ["id", "title"] })` |
| **Field Selection** | `["field1", {"nested": ["field2"]}]` | Relationships in objects |
| **Union Fields** | `{ unionField: ["member1", {"member2": [...]}] }` | Selective union member access |
| **Union Input** | `input: { field: { memberName: value } }` | Wrapped format required (exactly one member key) |
| **Calculation (no args)** | `{ calc: ["field1", ...] }` | Simple nested syntax |
| **Calculation (with args)** | `{ calc: { args: {...}, fields: [...] } }` | Args + fields object |
| **Filter Syntax** | `{ field: { eq: value } }` | Always use operator objects |
| **Sort** | `"-field"` or `["-field1", "field2"]` | `-`/`+`/`--`/`++` prefixes; array for multi-field |
| **CSRF Headers** | `headers: buildCSRFHeaders()` | Phoenix CSRF protection |
| **Input Args** | `input: { argName: value }` | Action arguments |
| **Identity (PK)** | `identity: "id-123"` | Primary key lookup |
| **Identity (Named)** | `identity: { email: "a@b.com" }` | Named identity lookup |
| **Identities Config** | `identities: [:_primary_key, :email]` | Allowed lookup methods |
| **Actor-Scoped** | `identities: []` | No identity param needed |
| **Get Action** | `get?: true` or `get_by: [:email]` | Single record lookup |
| **Not Found** | `not_found_error?: false` | Return null instead of error |
| **Custom Fetch** | `customFetch: myFetchFn` | Replace native fetch |
| **Pagination** | `page: { limit: 10 }` | Offset/keyset pagination |
| **Disable Filter** | `enable_filter?: false` | Disable client filtering |
| **Disable Sort** | `enable_sort?: false` | Disable client sorting |
| **Allowed Loads** | `allowed_loads: [:user, comments: [:author]]` | Whitelist loadable fields |
| **Denied Loads** | `denied_loads: [:user]` | Blacklist loadable fields |
| **Field Mapping** | `field_names [field_1: "field1"]` | Map invalid field names |
| **Arg Mapping** | `argument_names [action: [arg_1: "arg1"]]` | Map invalid arg names |
| **Type Mapping** | `def typescript_field_names, do: [...]` | NewType/TypedStruct callback |
| **Metadata Config** | `show_metadata: [:field1]` | Control metadata exposure |
| **Metadata Mapping** | `metadata_field_names: [field_1: "field1"]` | Map metadata names |
| **Metadata (Read)** | `metadataFields: ["field1"]` | Merged into records |
| **Metadata (Mutation)** | `result.metadata.field1` | Separate metadata field |
| **Domain Namespace** | `typescript_rpc do namespace "api"` | Default for all resources (strings only) |
| **Resource Namespace** | `resource X do namespace "todos"` | Override domain default |
| **Action Namespace** | `namespace: "custom"` | Override resource default |
| **Enable Namespaces** | `config :ash_typescript, enable_namespace_files: true` | Required — namespaces emit no files without it |
| **Deprecation** | `deprecated: true` or `"message"` | Mark action deprecated |
| **Related Actions** | `see: [:create_todo]` | Link in JSDoc |
| **Description** | `description: "Custom desc"` | Override JSDoc description |
| **Channel Function** | `actionNameChannel({channel, resultHandler})` | Phoenix channel RPC |
| **Validation Fn** | `validateActionName({...})` | Client-side validation |
| **Type Overrides** | `type_mapping_overrides: [{Module, "TSType"}]` | Map dependency types |
| **Typed Controller** | `use AshTypescript.TypedController` | Controller-style routes |
| **Controller Module** | `typed_controller do module_name MyWeb.Ctrl` | Generated controller module |
| **Verb Shortcut** | `get :auth do run fn ... end end` | Preferred route syntax |
| **Positional Method** | `route :login, :post do run fn ... end end` | Method as 2nd arg |
| **Default GET** | `route :home do run fn ... end end` | Method defaults to :get |
| **Route Argument** | `argument :code, :string, allow_nil?: false` | Colocated in route |
| **Array Argument** | `argument :tags, {:array, :string}` | Any Ash type form, arrays included |
| **Route Schema Names** | `zod_schema_name "x"` / `valibot_schema_name "x"` | Avoid collisions with RPC action schemas |
| **Route Namespace** | `namespace "auth"` | Inside typed_controller or route do block |
| **Route Description** | `description "..."` | JSDoc on route (inside do block) |
| **Route Deprecated** | `deprecated true` | Deprecation notice (inside do block) |
| **Route @see Tags** | `see [:auth, :logout]` | JSDoc `@see` cross-references |
| **Typed Controllers** | `config :ash_typescript, typed_controllers: [M]` | Module discovery |
| **Router Config** | `config :ash_typescript, router: MyWeb.Router` | Path introspection |
| **Routes Output** | `config :ash_typescript, routes_output_file: "routes.ts"` | Route file path |
| **Paths-Only Mode** | `config :ash_typescript, typed_controller_mode: :paths_only` | Skip fetch functions |
| **GET Query Params** | `argument :q, :string, allow_nil?: false` on GET route | Becomes `?q=value`; arrays become `?tags[]=a&tags[]=b` |
| **Route Validation Schemas** | Zod/Valibot schemas for any route with non-path args | Emitted into `ash_zod.ts` / `ash_valibot.ts`, GET included |
| **Typed Channel** | `use AshTypescript.TypedChannel` | Server-push event subscriptions |
| **Channel Topic** | `typed_channel do topic "org:*"` | Wildcard or static topic |
| **Channel Resource** | `resource MyApp.Post do publish :event end` | Declare events per resource |
| **Channel Create** | `createOrgChannel(socket, suffix)` | Factory with branded type |
| **Channel Subscribe** | `onOrgChannelMessages(channel, handlers)` | Multi-event subscription |
| **Channel Unsubscribe** | `unsubscribeOrgChannel(channel, refs)` | Cleanup all refs |
| **Typed Channels** | `config :ash_typescript, typed_channels: [M]` | Module discovery |
| **Channels Output** | `config :ash_typescript, typed_channels_output_file: "..."` | Channel functions file |
| **JSON Manifest** | `config :ash_typescript, json_manifest_file: "manifest.json"` | Machine-readable action metadata |
| **Manifest Filename** | `json_manifest_filename_format: :relative` | `:relative`, `:absolute`, or `:basename` |
## Action Feature Matrix
| Action Type | Fields | Filter | Page | Sort | Input | Identity |
|-------------|--------|--------|------|------|-------|----------|
| **read** | ✓ | ✓* | ✓ | ✓* | ✓ | - |
| **read (get?/get_by)** | ✓ | - | - | - | ✓ | - |
| **create** | ✓ | - | - | - | ✓ | - |
| **update** | ✓ | - | - | - | ✓† | ✓ |
| **destroy** | - | - | - | - | ✓ | ✓ |
*Can be disabled with `enable_filter?: false` / `enable_sort?: false`
†Update `input` is optional (`input?:`) — accepted attributes are never required on an update.
## Core Patterns
### Basic Setup
Requires ash `~> 3.27`. Three required pieces: a manifest module, the `manifest:` config, and a domain with the RPC extension. `mix igniter.install ash_typescript` scaffolds the first two.
```elixir
# 1. Manifest module (required — codegen and runtime read all shape from it)
defmodule MyApp.AshTypescriptManifest do
use AshTypescript.Manifest, otp_app: :my_app
end
# 2. config/config.exs
config :ash_typescript, manifest: MyApp.AshTypescriptManifest
# 3. Domain with RPC actions
defmodule MyApp.Domain do
use Ash.Domain, extensions: [AshTypescript.Rpc]
typescript_rpc do
resource MyApp.Todo do
rpc_action :list_todos, :read
rpc_action :create_todo, :create
rpc_action :update_todo, :update
end
end
end
```
### TypeScript Usage
Every RPC result is a discriminated union — `{ success: true; data } | { success: false; errors }`. Narrow on `success` before accessing `data`.
```typescript
// Read with all features
const todos = await listTodos({
fields: ["id", "title", { user: ["name"] }],
filter: { completed: { eq: false } },
page: { limit: 10 },
sort: "-createdAt",
headers: buildCSRFHeaders()
});
if (todos.success) console.log(todos.data);
// Update requires identity
await updateTodo({
identity: "todo-123",
input: { title: "Updated" },
fields: ["id", "title"]
});
// Phoenix channel
createTodoChannel({
channel: myChannel,
input: { title: "New" },
fields: ["id"],
resultHandler: (r) => {
if (r.success) console.log(r.data);
else console.error(r.errors);
}
});
```
### Field Name Mapping (Invalid Names)
```elixir
# Resource attributes/calculations
typescript do
field_names [field_1: "field1", is_active?: "isActive"]
argument_names [search: [filter_1: "filter1"]]
end
# Custom types (NewType, TypedStruct, map constraints)
def typescript_field_names, do: [field_1: "field1"]
# Metadata fields
rpc_action :read_todo_with_meta, :read_with_meta,
metadata_field_names: [meta_1: "meta1"]
```
## Typed Controller (Route Helpers)
### When to Use
| Use Case | Extension |
|----------|-----------|
| Data operations with field selection, filtering, pagination | `AshTypescript.Rpc` + `AshTypescript.Resource` |
| Controller actions (Inertia renders, redirects, file downloads) | `AshTypescript.TypedController` |
### Setup
```elixir
defmodule MyApp.Session do
use AshTypescript.TypedController
typed_controller do
module_name MyAppWeb.SessionController
# Verb shortcut (preferred)
get :auth do
run fn conn, _params -> render_inertia(conn, "Auth") end
end
# Verb shortcut with args
post :login do
see [:auth, :logout]
run fn conn, _params -> Plug.Conn.send_resp(conn, 200, "OK") end
argument :code, :string, allow_nil?: false
argument :remember_me, :boolean
end
# Positional method arg
route :logout, :post do
run fn conn, _params -> Plug.Conn.send_resp(conn, 200, "OK") end
end
# Default method (GET when omitted)
route :home do
run fn conn, _params -> Plug.Conn.send_resp(conn, 200, "Home") end
end
end
end
```
### Generated TypeScript
```typescript
// GET → path helper
authPath() // → "/auth"
// GET with query args → path with query params
searchPath({ q: "test", page: 1 }) // → "/search?q=test&page=1"
// POST → typed async function (via executeTypedControllerRequest helper)
login({ code: "abc" }, { headers: { "X-CSRF-Token": token } })
// PATCH with path params + input
updateProvider({ provider: "github" }, { enabled: true })
```
**Function parameter order**: `path` (if path params) → `input` (if args) → `config?: TypedControllerConfig`
**Modes**: `:full` generates path helpers + fetch functions (+ Zod schemas if enabled). `:paths_only` generates only path helpers.
### Typed Controller Constraints
- Handlers must return `%Plug.Conn{}` directly — no `{:ok, conn}` wrapping
- Multi-mount requires unique `as:` options on scopes for disambiguation
- Not an Ash resource — standalone Spark DSL with colocated arguments
- Path param `allow_nil?` must match presence: always present → `false`, sometimes present (multi-mount) → `true`
- Arguments follow Ash action-argument semantics: `constraints:` are validated at compile time and enforced at runtime — a failure returns 422 without invoking the handler
- Empty string on a required string argument → 422 `is required`; on a nilable string argument the handler receives `nil`. Strings are trimmed by default (`trim?`)
- Route arguments accept any Ash type form, including `{:array, inner}`; item constraints go under `constraints: [items: [...]]`
- Validation schemas are generated for every route with non-path arguments (GET query params included), not just mutations — they live in the shared `ash_zod.ts`/`ash_valibot.ts`, and the JSON manifest advertises them under `typedControllerRoutes[].types`
## Typed Channel (Event Subscriptions)
### When to Use
| Use Case | Extension |
|----------|-----------|
| Data operations with field selection, filtering, pagination | `AshTypescript.Rpc` + `AshTypescript.Resource` |
| Controller actions (Inertia renders, redirects, file downloads) | `AshTypescript.TypedController` |
| Server pushes events to clients (notifications, updates) | `AshTypescript.TypedChannel` |
### Setup
```elixir
defmodule MyAppWeb.OrgChannel do
use AshTypescript.TypedChannel
use Phoenix.Channel
typed_channel do
topic "org:*"
resource MyApp.Post do
publish :post_created
publish :post_updated
end
end
@impl true
def join("org:" <> org_id, _payload, socket), do: {:ok, socket}
end
```
Resources must have `pub_sub` publications with matching `event:` names. For typed payloads, use `transform :some_calc` referencing an `:auto`-typed calculation (recommended — Ash derives `returns` automatically), or set `returns:` explicitly. Without either, the payload type is `unknown`.
The module must also `use Phoenix.Channel` (compile warning otherwise): the declared events are automatically intercepted and their payloads formatted with the configured `output_field_formatter` before being pushed, so the wire payload matches the generated TypeScript types.
### Generated TypeScript
```typescript
// Create branded channel + subscribe
const channel = createOrgChannel(socket, orgId);
const refs = onOrgChannelMessages(channel, {
post_created: (payload) => console.log(payload), // typed payload
post_updated: (payload) => updatePost(payload),
});
// Single event: onOrgChannelMessage(channel, "post_created", handler)
// Cleanup
unsubscribeOrgChannel(channel, refs);
```
### Topic Patterns
| Topic Pattern | Factory Signature |
|--------------|-------------------|
| `"org:*"` (wildcard) | `createOrgChannel(socket, suffix)` |
| `"global"` (no wildcard) | `createGlobalChannel(socket)` |
### Typed Channel Constraints
- Event names must be unique across all resources in a channel
- Publications need `public?: true` (warning if missing — toggle with `warn_on_non_public_publications`)
- Publications need `transform :some_calc` or an explicit `returns:` for typed payloads (warning if missing, falls back to `unknown` — toggle with `warn_on_missing_channel_returns`)
- The branded channel type exposes `on`/`off`/`join`/`leave` — cast to Phoenix's `Channel` if you need anything else (e.g. `push`)
- Channel types go in `ash_types.ts`; channel functions go in `typed_channels_output_file`
## JSON Manifest (Third-Party Integrations)
When `json_manifest_file` is configured, `mix ash_typescript.codegen` generates a machine-readable JSON manifest. This enables third-party packages (e.g., TanStack Query wrappers) to introspect the generated API without coupling to ash_typescript internals.
```elixir
config :ash_typescript,
json_manifest_file: "assets/js/ash_rpc_manifest.json",
json_manifest_filename_format: :relative # :relative | :absolute | :basename
```
The manifest contains:
- **`files`** — generated file locations with `importPath` (for TS imports, always relative, no `.ts`) and `filename` (format controlled by config)
- **`actions`** — every RPC action with: `functionName`, `actionType` (read/create/update/destroy/action), `get`, `namespace`, `resource`, `description`, `types` (result, fields, inferResult, input, config, filterInput — only present when applicable), `pagination`, `enableFilter`, `enableSort`, `variants`/`variantNames`, `deprecated`, `see`, `input` (none/optional/required)
- **`typedControllerRoutes`** — each route with: `functionName`, `method`, `path`, `pathParams`, `mutation`, plus `types` when the route has input arguments
- **`version`** — semver string (currently `"1.0"`) for consumer compatibility, and `generatedAt` (date string)
### Consumer Example
```typescript
import manifest from "./ash_rpc_manifest.json";
for (const action of manifest.actions) {
const isQuery = action.actionType === "read";
// Import from manifest.files.rpc.importPath
// Generate queryOptions/mutationOptions wrappers
}
```
## Common Gotchas
| Error Pattern | Fix |
|---------------|-----|
| "No `:manifest` module configured" | Add `config :ash_typescript, manifest: MyApp.AshTypescriptManifest` + define the module (`use AshTypescript.Manifest, otp_app: :my_app`) |
| Missing `extensions: [AshTypescript.Rpc]` | Add to domain |
| Missing `typescript` block on resource | Add `AshTypescript.Resource` extension + `typescript do type_name "X" end` |
| No `rpc_action` declarations | Explicitly declare each action |
| Compile error points at your manifest module | Expected — all RPC verifiers run when the manifest module compiles, not the domain |
| Filter syntax `{ field: false }` | Use operators: `{ field: { eq: false } }` |
| "Union input must be a map" / "contains multiple member keys" | Wrap union inputs in exactly one member key: `{ memberName: value }` |
| Missing `fields` parameter | Always include `fields: [...]` |
| Get action error on not found | Add `not_found_error?: false` |
| Invalid field name `field_1` or `is_active?` | Add field mapping |
| Identity not found | Check `identities` config; use `{ field: value }` for named |
| Load not allowed/denied | Check `allowed_loads`/`denied_loads` config |
| Channel/validation fn undefined | Enable in config |
| Typed controller 500 error | Handler must return `%Plug.Conn{}` |
| Routes not generated | Set `typed_controllers:` (gates generation) and `router:` in config; `routes_output_file:` auto-derives |
| Multi-mount ambiguity error | Add unique `as:` option to each scope |
| Path param without matching argument | Add `argument :param, :string` to route |
| Path param `allow_nil?` mismatch | Always-present → `false`; sometimes-present → `true` |
| Route hooks not firing | Check `typed_controller_import_into_generated` + hook names |
| Typed channel event not found | Event name must match `event:` option on resource's `pub_sub` publication |
| Duplicate channel event names | Use unique event names across all resources in one channel |
| Channel payload is `unknown` | Use `transform :some_calc` (an `:auto`-typed calculation) on the publication, or add explicit `returns:` |
| TypedMap field with embedded-resource member rejects string selection | Use nested selection: `{ field: [{ rows: [...] }] }` (composite members require explicit field selection since 0.18) |
| Typed channels not generated | Set `typed_channels:` and `typed_channels_output_file:` in config |
## Error Quick Reference
| Error Contains | Fix |
|----------------|-----|
| "No `:manifest` module configured" | Define manifest module + set `manifest:` config |
| "Property does not exist" | Run `mix ash_typescript.codegen` |
| "Property 'fields' is missing" | Add `fields: [...]` |
| "Action not found" | Add `rpc_action` declaration |
| "403 Forbidden" | Use `buildCSRFHeaders()` |
| "Invalid field names" | Add mapping (see Field Name Mapping) |
| "load_not_allowed" / "load_denied" | Check load restrictions config |
| "show_metadata contains unknown metadata fields" | Only list fields the action's `metadata` declarations define |
| "allowed_loads contains invalid load paths" | Entries must be public relationships/calculations/aggregates/embedded attributes; nested keys checked against the destination resource |
| "allow_nil?: true" + path param | Set `allow_nil?: false` for always-present path params |
| "allow_nil?: false" + sometimes-present | Use `allow_nil?: true` for multi-mount path params |
| "No publication with event X found" | Check `event:` option on resource's `pub_sub` block |
| "Duplicate event names found" | Use unique event names per channel |
## Configuration
```elixir
config :ash_typescript,
manifest: MyApp.AshTypescriptManifest, # Required: app-wide manifest module
output_file: "assets/js/ash_rpc.ts",
run_endpoint: "/rpc/run",
validate_endpoint: "/rpc/validate",
generate_validation_functions: false,
generate_phx_channel_rpc_actions: false,
generate_zod_schemas: false,
generate_valibot_schemas: false,
require_tenant_parameters: false,
not_found_error?: true,
# JSDoc/Manifest
add_ash_internals_to_jsdoc: false,
add_ash_internals_to_manifest: false,
manifest_file: nil,
json_manifest_file: nil, # Machine-readable JSON manifest for third-party tools
json_manifest_filename_format: :relative, # :relative | :absolute | :basename
source_path_prefix: nil, # For monorepos: "backend"
# Namespace files (namespaces emit nothing unless enabled)
enable_namespace_files: false,
namespace_output_dir: nil, # Defaults to the RPC output file's directory
enable_controller_namespace_files: false,
controller_namespace_output_dir: nil,
# Warnings
warn_on_missing_rpc_config: true,
warn_on_non_rpc_references: true,
warn_on_non_public_publications: true,
warn_on_missing_channel_returns: true,
# Dev codegen behavior
always_regenerate: false,
# Imports/Types
import_into_generated: [%{import_name: "CustomTypes", file: "./customTypes"}],
type_mapping_overrides: [{MyApp.CustomType, "string"}],
# Typed Controller (route helpers)
typed_controllers: [MyApp.Session],
router: MyAppWeb.Router,
routes_output_file: "assets/js/routes.ts",
typed_controller_mode: :full, # :full or :paths_only
typed_controller_path_params_style: :object, # :object or :args
# typed_controller_base_path: "https://api.example.com", # or {:runtime_expr, "..."}
# Optional: lifecycle hooks, custom imports, error handling
# 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"}],
# typed_controller_error_handler: {MyApp.ErrorHandler, :handle, []},
# typed_controller_show_raised_errors: false # true only in dev
# Typed Channel (event subscriptions)
typed_channels: [MyApp.OrgChannel],
typed_channels_output_file: "assets/js/ash_typed_channels.ts"
```
Not exhaustive — see `documentation/reference/configuration.md` for the full key list.
**Validation schemas**: generated Zod/Valibot string schemas carry `min(1)` whenever the attribute's constraints have `allow_empty?: false` (the Ash default), including nullable and optional fields. Send `null`, not `""`.
## Commands
```bash
mix igniter.install ash_typescript # Scaffold config + manifest module
mix ash_typescript.codegen # Generate
mix ash_typescript.codegen --check # Verify up-to-date (CI)
mix ash_typescript.codegen --dry-run # Preview
npx tsc ash_rpc.ts --noEmit # Validate TS
```