Current section

Files

Jump to
ash_typescript usage-rules.md
Raw

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**: 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 |
| **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 String** | `"-field1,field2"` | Dash prefix = descending |
| **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 |
| **Resource Namespace** | `resource X do namespace :todos` | Override domain default |
| **Action Namespace** | `namespace: :custom` | Override resource default |
| **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 |
| **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` |
| **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`
## Core Patterns
### Basic Setup
```elixir
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
```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()
});
// 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) => console.log(r.data)
});
```
### 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, :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`
## 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. Add `returns:` to publications for typed payloads (otherwise `unknown`).
### Generated TypeScript
```typescript
// Create branded channel + subscribe
const channel = createOrgChannel(socket, orgId);
channel.join();
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)
- Publications need `returns:` option for typed payloads (warning if missing, falls back to `unknown`)
- 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`, `types` (result, fields, 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`, `types`
- **`version`** — semver string (currently `"1.0"`) for consumer compatibility
### 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 |
|---------------|-----|
| 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 |
| Filter syntax `{ field: false }` | Use operators: `{ field: { eq: false } }` |
| 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:`, `router:`, and `routes_output_file:` in config |
| 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` | Add `returns:` option to the resource's `pub_sub` publication |
| Typed channels not generated | Set `typed_channels:` and `typed_channels_output_file:` in config |
## Error Quick Reference
| Error Contains | Fix |
|----------------|-----|
| "Property does not exist" | Run `mix ash_typescript.codegen` |
| "fields is required" | Add `fields: [...]` |
| "No domains found" | Use `MIX_ENV=test` for test resources |
| "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 |
| "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,
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,
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"
# Warnings
warn_on_missing_rpc_config: true,
warn_on_non_rpc_references: 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
# 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"
```
## Commands
```bash
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
```