AshTypescript generates TypeScript types and RPC clients from Ash resources, providing end-to-end type safety between Elixir backends and TypeScript frontends.
Key Features: Type generation, RPC client generation, Phoenix channel RPC actions, typed channel event subscriptions, typed controller route helpers, action metadata support, nested calculations, multitenancy, embedded resources, union types, field/argument/metadata name mapping, load restrictions, configurable RPC warnings, JSON manifest for third-party integrations
| ❌ Wrong | ✅ Correct | Purpose |
|---|---|---|
mix ash_typescript.codegen |
mix test.codegen |
Generate types |
| One-off shell debugging | Write proper tests | Debug issues |
Why: Test resources (AshTypescript.Test.*) only compile in :test environment. Using dev environment causes "No domains found" errors.
For any complex task (3+ steps):
- Check documentation index below to find relevant documentation
- Read recommended docs first to understand patterns
- Then implement following established patterns
Skip documentation → broken implementations, wasted time
mix test.codegen # Generate TypeScript types
cd test/ts && npm run compileGenerated # Validate compilation
mix test # Run Elixir testsdefmodule MyApp.Domain do
use Ash.Domain, extensions: [AshTypescript.Rpc]
typescript_rpc do
resource MyApp.Todo do
rpc_action :list_todos, :read
rpc_action :list_todos_no_filter, :read, enable_filter?: false # Disable client filtering
rpc_action :list_todos_no_sort, :read, enable_sort?: false # Disable client sorting
# Load restrictions - control which relationships/calculations clients can load
rpc_action :list_todos_limited, :read, allowed_loads: [:user] # Whitelist
rpc_action :list_todos_no_user, :read, denied_loads: [:user] # Blacklist
rpc_action :list_todos_nested, :read, allowed_loads: [comments: [:author]] # Nested
end
end
endThree syntaxes are supported for defining routes:
defmodule MyApp.Session do
use AshTypescript.TypedController
typed_controller do
module_name MyAppWeb.SessionController
# Verb shortcut (preferred) — method is the entity name
get :auth do
run fn conn, _params -> render_inertia(conn, "Auth") end
end
# Positional method arg
route :login, :post do
run fn conn, _params -> Plug.Conn.send_resp(conn, 200, "OK") end
argument :code, :string, allow_nil?: false
end
# Method defaults to :get when omitted
route :home do
run fn conn, _params -> Plug.Conn.send_resp(conn, 200, "Home") end
end
end
endimport { listTodos, buildCSRFHeaders } from './ash_rpc';
const todos = await listTodos({
fields: ["id", "title", {"user" => ["name"]}],
headers: buildCSRFHeaders()
});Generated Channel Functions: AshTypescript generates channel functions with Channel suffix:
import { Channel } from "phoenix";
import { listTodos, listTodosChannel } from './ash_rpc';
// HTTP-based (always available)
const httpResult = await listTodos({
fields: ["id", "title"],
headers: buildCSRFHeaders()
});
// Channel-based (when enabled)
listTodosChannel({
channel: myChannel,
fields: ["id", "title"],
resultHandler: (result) => {
if (result.success) {
console.log("Todos:", result.data);
} else {
console.error("Error:", result.errors);
}
},
errorHandler: (error) => console.error("Channel error:", error),
timeoutHandler: () => console.error("Timeout")
});For typed one-way push events from Ash PubSub publications.
Recommended: Use transform :some_calc to reference a resource calculation.
Ash auto-derives the returns type when the calculation uses :auto, so
AshTypescript gets the type information it needs without manual returns declarations.
# Resource with calculation transforms (recommended)
defmodule MyApp.Post do
use Ash.Resource, notifiers: [Ash.Notifier.PubSub]
pub_sub do
module MyApp.Endpoint
prefix "posts"
publish :create, [:id], event: "post_created", public?: true, transform: :post_summary
publish :update, [:id], event: "post_updated", public?: true, transform: :post_summary
end
calculations do
calculate :post_summary, :auto, expr(%{id: id, title: title}) do
public? true
end
end
# ...
end
# Channel definition (unchanged — only references events)
defmodule MyApp.OrgChannel do
use AshTypescript.TypedChannel
typed_channel do
topic "org:*"
resource MyApp.Post do
publish :post_created
publish :post_updated
end
end
endYou can also use explicit returns with an anonymous function transform, but
this requires manually keeping the type and transform in sync:
publish :create, [:id],
event: "post_created",
public?: true,
returns: :map,
constraints: [fields: [id: [type: :uuid], title: [type: :string]]],
transform: fn notification -> %{id: notification.data.id, title: notification.data.title} endimport { createOrgChannel, onOrgChannelMessages, unsubscribeOrgChannel } from './ash_typed_channels';
const channel = createOrgChannel(socket, orgId);
const refs = onOrgChannelMessages(channel, {
post_created: (payload) => console.log("New post:", payload),
post_updated: (payload) => console.log("Updated:", payload),
});
// Cleanup: unsubscribeOrgChannel(channel, refs);Use these tools instead of shell commands for Elixir evaluation:
| Tool | Purpose |
|---|---|
mcp__tidewave__project_eval |
Primary tool - evaluate Elixir in project context |
mcp__tidewave__get_docs |
Get module/function documentation |
mcp__tidewave__get_source_location |
Find source locations |
Debug Examples:
# Debug field processing
mcp__tidewave__project_eval("""
fields = ["id", {"user" => ["name"]}]
AshTypescript.Rpc.RequestedFieldsProcessor.process(
AshTypescript.Test.Todo, :read, fields
)
""")| Purpose | Location |
|---|---|
| Core type generation (entry point) | lib/ash_typescript/codegen.ex (delegator) |
| Type system introspection | lib/ash_typescript/type_system/introspection.ex |
| Resource discovery | lib/ash_typescript/codegen/type_discovery.ex |
| Type aliases generation | lib/ash_typescript/codegen/type_aliases.ex |
| TypeScript type mapping | lib/ash_typescript/codegen/type_mapper.ex |
| Resource schema generation | lib/ash_typescript/codegen/resource_schemas.ex |
| Filter types generation | lib/ash_typescript/codegen/filter_types.ex |
| Sort types generation | lib/ash_typescript/codegen/sort_types.ex |
| Zod schema generation | lib/ash_typescript/codegen/zod_schema_generator.ex |
| Valibot schema generation | lib/ash_typescript/codegen/valibot_schema_generator.ex |
| Utility types generation | lib/ash_typescript/codegen/utility_types.ex |
| Import path resolution | lib/ash_typescript/codegen/import_resolver.ex |
| Shared types generator | lib/ash_typescript/codegen/shared_types_generator.ex |
| Shared schema generator | lib/ash_typescript/codegen/shared_schema_generator.ex |
| Schema formatter behaviour | lib/ash_typescript/codegen/schema_formatter.ex |
| Schema core (shared logic) | lib/ash_typescript/codegen/schema_core.ex |
| Multi-file orchestrator | lib/ash_typescript/codegen/orchestrator.ex |
| RPC client generation | lib/ash_typescript/rpc/codegen.ex |
| JSDoc comment generation | lib/ash_typescript/rpc/codegen/function_generators/jsdoc_generator.ex |
| Manifest generation (Markdown) | lib/ash_typescript/rpc/codegen/manifest_generator.ex |
| Manifest generation (JSON) | lib/ash_typescript/rpc/codegen/json_manifest_generator.ex |
| Namespace resolution | lib/ash_typescript/rpc/codegen/rpc_config_collector.ex |
| Pipeline orchestration | lib/ash_typescript/rpc/pipeline.ex |
| Field processing (entry point) | lib/ash_typescript/rpc/requested_fields_processor.ex (delegator) |
| Field atomization | lib/ash_typescript/rpc/field_processing/atomizer.ex |
| Field selection (type-driven) | lib/ash_typescript/rpc/field_processing/field_selector.ex |
| Field validation helpers | lib/ash_typescript/rpc/field_processing/field_selector/validation.ex |
| Result extraction | lib/ash_typescript/rpc/result_processor.ex |
| Unified value formatting | lib/ash_typescript/rpc/value_formatter.ex |
| Input formatting | lib/ash_typescript/rpc/input_formatter.ex (delegates to ValueFormatter) |
| Output formatting | lib/ash_typescript/rpc/output_formatter.ex (delegates to ValueFormatter) |
| Resource verifiers | lib/ash_typescript/resource/verifiers/ |
| Typed controller DSL | lib/ash_typescript/typed_controller/dsl.ex |
| Typed controller main | lib/ash_typescript/typed_controller.ex |
| Controller request handler | lib/ash_typescript/typed_controller/request_handler.ex |
| Controller codegen | lib/ash_typescript/typed_controller/codegen.ex |
| Controller config discovery | lib/ash_typescript/typed_controller/codegen/route_config_collector.ex |
| Router introspection | lib/ash_typescript/typed_controller/codegen/router_introspector.ex |
| Route renderer | lib/ash_typescript/typed_controller/codegen/route_renderer.ex |
| TypeScript static code | lib/ash_typescript/typed_controller/codegen/typescript_static.ex |
| Controller verifier | lib/ash_typescript/typed_controller/verifiers/verify_typed_controller.ex |
| Typed channel DSL | lib/ash_typescript/typed_channel/dsl.ex |
| Typed channel main | lib/ash_typescript/typed_channel.ex |
| Typed channel codegen | lib/ash_typescript/typed_channel/codegen.ex |
| Typed channel verifier | lib/ash_typescript/typed_channel/verifiers/verify_typed_channel.ex |
| Channel payload type mapper | lib/ash_typescript/codegen/type_mapper.ex (map_channel_payload_type/2) |
| Test domain | test/support/domain.ex |
| Primary test resource | test/support/resources/todo.ex |
| TypeScript validation | test/ts/shouldPass/ & test/ts/shouldFail/ |
| TypeScript call extractor | test/support/ts_action_call_extractor.ex |
| Codegen test helper | test/support/codegen_test_helper.ex |
| Typed controller tests | test/ash_typescript/typed_controller/ |
| Typed channel tests | test/ash_typescript/typed_channel/ |
| Test typed controller | test/support/resources/session.ex |
| Test router | test/support/routes_test_router.ex |
| Generated route helpers | test/ts/generated_routes.ts |
mix test.codegen # Generate TypeScript (main command)
mix test.codegen --dry-run # Preview output
mix test # Run all tests (do NOT prefix with MIX_ENV=test)
mix test test/ash_typescript/rpc/ # Test RPC functionalitynpm run compileGenerated # Test generated types compile
npm run compileShouldPass # Test valid patterns (type-level)
npm run compileShouldFail # Test invalid patterns fail (type-level)
npm run testZod # Run generated Zod schemas against real data
npm run testValibot # Run generated Valibot schemas against real datatestZod / testValibot compile and execute the generated validation
schemas against fixture inputs — they are the only path that exercises schema
runtime behavior (e.g. catches a bug like an empty z.object({}) for a type
that should validate { amount, currency }). Always run them after touching
third_party_types, constraint generation, or any other validation codegen.
mix format # Code formatting
mix credo --strict # Linting| File | Purpose |
|---|---|
| troubleshooting.md | Development troubleshooting |
| testing-and-validation.md | Test organization and validation procedures |
| architecture-decisions.md | Architecture decisions and context |
| File | Purpose |
|---|---|
| run-ts.md | Plan for TypeScript runtime validation - executing extracted TS calls via RPC |
Consult these when modifying core systems:
| Working On | See Documentation | Test Files |
|---|---|---|
| Type generation or custom types | features/type-system.md | test/ash_typescript/typescript_codegen_test.exs |
| Field/argument name mapping | features/field-argument-name-mapping.md | test/ash_typescript/rpc/rpc_field_argument_mapping_test.exs |
| Action metadata | features/action-metadata.md | test/ash_typescript/rpc/rpc_metadata_test.exs, test/ash_typescript/rpc/verify_metadata_field_names_test.exs |
| RPC pipeline or field processing | features/rpc-pipeline.md | test/ash_typescript/rpc/rpc_*_test.exs |
| Load restrictions | features/rpc-pipeline.md (RPC Action Options) | test/ash_typescript/rpc/load_restrictions_test.exs |
| Validation schemas (Zod & Valibot) | features/validation-schemas.md | test/ash_typescript/rpc/zod_constraints_test.exs, test/ash_typescript/rpc/valibot_constraints_test.exs |
| Embedded resources | features/embedded-resources.md | test/support/resources/embedded/ |
| Union types | features/union-systems-core.md | test/ash_typescript/rpc/rpc_union_*_test.exs |
| Namespaces, JSDoc, Manifest, JSON Manifest | features/developer-experience.md | test/ash_typescript/rpc/namespace_test.exs, test/ash_typescript/rpc/json_manifest_generator_test.exs |
| Typed controllers & route helpers | features/typed-controller.md | test/ash_typescript/typed_controller/ |
| Typed channel event subscriptions | features/typed-channel.md | test/ash_typescript/typed_channel/ |
| Development patterns | development-workflows.md | N/A |
- parse_request - Validate input, create extraction templates
- execute_ash_action - Run Ash operations
- process_result - Apply field selection using templates
- format_output - Format for client consumption
- RequestedFieldsProcessor (delegator) - Entry point for field processing
- Field Processing Subsystem - 3 modules using type-driven dispatch:
Atomizer- Converts client field names to internal atomsFieldSelector- Unified type-driven field selection (mirrorsValueFormatterpattern)FieldSelector.Validation- Field validation helpers
- ResultProcessor - Result extraction using templates
- Pipeline - Four-stage orchestration
- ErrorBuilder - Comprehensive error handling
- ValueFormatter - Unified type-aware value formatting
- Orchestrator (
codegen/orchestrator.ex): Coordinates all file generation — types, Zod, Valibot, RPC, routes, typed channels, namespace re-exports - SchemaCore (
codegen/schema_core.ex): Shared validation schema logic (topological sort, type mapping, field introspection) used by both Zod and Valibot viaSchemaFormatterbehaviour - ImportResolver (
codegen/import_resolver.ex): Shared utility for import path resolution and namespace re-export generation (used by both RPC and controller codegen) - CodegenTestHelper (
test/support/codegen_test_helper.ex): Test wrapper for orchestrator — usegenerate_all_content/0for string assertions,generate_files/0for file-level assertions
- Type Introspection: Centralized in
type_system/introspection.ex - Codegen Organization: Focused modules (type_discovery, type_aliases, type_mapper, resource_schemas, filter_types, sort_types)
- ValueFormatter: Unified type-aware value formatting with recursive type detection
- Unified Schema: Single ResourceSchema with
__typemetadata - Schema Keys: Direct classification via key lookup
- Utility Types:
UnionToIntersection,InferFieldValue,InferResult,SortString - Sort Types: Per-resource
{Resource}SortFieldunion types and{resource}SortFieldsconst arrays generated bysort_types.ex. RPC functions useSortString<TodoSortField>to provide type-safe sort parameters with+/-/++/--prefix support.
- Field Selection: Unified format supporting nested relationships and calculations
- Embedded Resources: Full relationship-like architecture with calculation support
- Union Field Selection: Selective member fetching with
{content: ["field1", {"nested": ["field2"]}]} - Union Input Format: REQUIRED wrapped format
{member_name: value}for all union inputs - Headers Support: All RPC functions accept optional headers for custom authentication
- Type-Driven Dispatch: Both
FieldSelectorandValueFormatteruse{type, constraints}pattern for recursive processing
| Error | Cause | Solution |
|---|---|---|
| "No domains found" | Using dev environment | Use mix test.codegen |
| "Module not loaded" | Test resources not compiled | Ensure MIX_ENV=test |
| "Invalid field names found" | Field/arg with _1 or ? |
Use field_names or argument_names DSL options |
| "Invalid field names in map/keyword/tuple" | Map constraint fields invalid | Create Ash.Type.NewType with typescript_field_names/0 callback |
| "Invalid metadata field name" | Metadata field with _1 or ? |
Use metadata_field_names DSL option in rpc_action |
| "Metadata field conflicts with resource field" | Metadata field shadows resource field | Rename metadata field or use different mapped name |
TypeScript unknown types |
Schema key mismatch | Check __type metadata generation |
| Field selection fails | Invalid field format | Use unified field format only |
| "Union input must be a map" | Direct value for union input | Wrap in map: {member_name: value} |
| "Union input map contains multiple member keys" | Multiple union members in input | Provide exactly one member key |
| "Union input map does not contain any valid member key" | Invalid or missing member key | Use valid member name from union definition |
| Test reads stale generated.ts | Test uses File.read!("test/ts/generated.ts") |
Use AshTypescript.Test.CodegenTestHelper.generate_all_content/0 in setup_all |
| Controller 422 error | Missing required argument or invalid type cast | Check allow_nil? and argument types |
| Controller 500 error | Handler doesn't return %Plug.Conn{} |
Return %Plug.Conn{} from handler |
| Routes not generated | Missing config | Set typed_controllers:, router:, and routes_output_file: in config |
| Multi-mount ambiguity | Duplicate mounts without as: |
Add unique as: to each scope |
| "load_not_allowed" error | Requested field not in allowed_loads |
Add field to allowed_loads or remove the option |
| "load_denied" error | Requested field in denied_loads |
Remove field from denied_loads list |
| Path param without matching argument | Router path has :param but no DSL argument |
Add argument :param, :string to the route definition |
| Invalid names for TypeScript (controller) | Route/argument names with _1 or ? |
Rename to avoid patterns that produce awkward camelCase |
allow_nil?: true on always-present path param |
Path param always provided by router | Set allow_nil?: false on the argument |
allow_nil?: false on sometimes-present path param |
Path param only at some mounts | Set allow_nil?: true (default) on the argument |
| "No publication with event X found" | Typed channel event doesn't match any publication | Check event: option on the resource's pub_sub block |
| "Duplicate event names found in typed_channel" | Same event name across resources in one channel | Use unique event names per channel |
| "Payload type name conflict" | Same event name across different channels maps to different TS types | Rename events or ensure same returns type |
Channel unknown payload type |
Publication missing returns type (no transform :calc or explicit returns) |
Use transform :some_calc with an :auto-typed calculation (recommended), or add explicit returns: |
"not public?" error on RPC action |
Action has public? false |
Set public? true on the action or remove it from typescript_rpc |
"not public?" error on read_action |
read_action has public? false |
Set public? true on the read action |
"not public?" error on relationship read action |
Relationship destination's read action has public? false |
Set public? true on the destination's read action |
AshTypescript provides compile-time warnings for potential RPC configuration issues:
Message: ⚠️ Found resources with AshTypescript.Resource extension but not listed in any domain's typescript_rpc block
Cause: Resource has AshTypescript.Resource extension but isn't configured in any typescript_rpc block
Solutions:
- Add resource to a domain's
typescript_rpcblock, OR - Remove
AshTypescript.Resourceextension if not needed, OR - Disable warning:
config :ash_typescript, warn_on_missing_rpc_config: false
Message: ⚠️ Found non-RPC resources referenced by RPC resources
Cause: RPC resource references another resource (in attribute/calculation/aggregate) that isn't itself configured as RPC
Solutions:
- Add referenced resource to
typescript_rpcblock if it should be accessible, OR - Leave as-is if resource is intentionally internal-only, OR
- Disable warning:
config :ash_typescript, warn_on_non_rpc_references: false
Note: Both warnings can be independently configured. See Configuration Reference for details.
When typed_controllers, router, and routes_output_file are configured, mix ash_typescript.codegen generates typed TypeScript route helpers alongside RPC types.
Configuration:
config :ash_typescript,
typed_controllers: [MyApp.Session], # TypedController modules
router: MyAppWeb.Router, # Phoenix router for path introspection
routes_output_file: "assets/js/routes.ts", # Output file for route helpers
typed_controller_mode: :full, # :full (default) or :paths_only
typed_controller_base_path: "" # Base URL prefix (string or {:runtime_expr, "..."})Modes: :full generates path helpers + typed fetch functions for mutations. :paths_only generates only path helpers.
Base path: When set (e.g., "https://api.example.com" or {:runtime_expr, "AppConfig.getBasePath()"}), all generated route URLs are prefixed with _basePath. Uses the same {:runtime_expr, "..."} pattern as RPC endpoints.
Implementation: lib/ash_typescript.ex (typed_controllers/0, router/0, routes_output_file/0, typed_controller_mode/0, typed_controller_base_path/0) + lib/mix/tasks/ash_typescript.codegen.ex + lib/ash_typescript/typed_controller/
When typed_channels and typed_channels_output_file are configured, mix ash_typescript.codegen generates typed TypeScript event subscription helpers alongside RPC types.
Configuration:
config :ash_typescript,
typed_channels: [MyApp.OrgChannel], # TypedChannel modules
typed_channels_output_file: "assets/js/ash_typed_channels.ts" # Output file for channel functionsChannel types (branded types, payload aliases, event maps) are appended to ash_types.ts. Channel functions (factory, subscription helpers) go into the separate typed_channels_output_file.
Implementation: lib/ash_typescript.ex (typed_channels/0, typed_channels_output_file/0) + lib/ash_typescript/typed_channel/ + lib/ash_typescript/codegen/orchestrator.ex
When json_manifest_file is configured, mix ash_typescript.codegen generates a machine-readable JSON manifest alongside the TypeScript output. This manifest contains structured metadata about every RPC action (function names, types, pagination, variants) and typed controller routes, enabling third-party packages to build typed wrappers (e.g., TanStack Query integrations) without coupling to ash_typescript internals.
Configuration:
config :ash_typescript,
json_manifest_file: "assets/js/ash_rpc_manifest.json",
json_manifest_filename_format: :relative # :relative (default) | :absolute | :basenamejson_manifest_filename_formatcontrols thefilenamefield in eachfilesentry.importPath(no.ts, for TypeScript imports) is always relative to the manifest.- The manifest includes a
"version": "1.0"field using semver for consumer compatibility detection. - The manifest is written independently of other file changes — it's always generated if the file doesn't exist or content changed.
Implementation: lib/ash_typescript/rpc.ex (json_manifest_file/0, json_manifest_filename_format/0) + lib/ash_typescript/rpc/codegen/json_manifest_generator.ex + lib/mix/tasks/ash_typescript.codegen.ex
When config :ash_typescript, always_regenerate: true is set, mix ash_typescript.codegen --check writes files directly instead of comparing and raising Ash.Error.Framework.PendingCodegen. This is useful in development with AshPhoenix.Plug.CheckCodegenStatus to avoid the stale codegen error page and always regenerate files on every request.
Configuration: config :ash_typescript, always_regenerate: true (default: false)
Implementation: lib/ash_typescript.ex (always_regenerate?/0) + lib/mix/tasks/ash_typescript.codegen.ex
mix test.codegen # Generate types
cd test/ts && npm run compileGenerated # Validate compilation
npm run compileShouldPass # Test valid patterns (type-level)
npm run compileShouldFail # Test invalid patterns fail (type-level)
npm run testZod # Run generated Zod schemas at runtime
npm run testValibot # Run generated Valibot schemas at runtime
mix test # Run Elixir tests (do NOT prefix with MIX_ENV=test)- ✅ Always validate TypeScript compilation after changes
- ✅ Test both valid and invalid usage patterns
- ✅ Use test environment for all AshTypescript commands
- ✅ Write proper tests for debugging (no one-off shell commands)
- ✅ Check architecture-decisions.md for context on current patterns
🎯 Primary Goal: Generate type-safe TypeScript clients from Ash resources with full feature support and optimal developer experience.