This document provides a comprehensive reference for all AshTypescript configuration options.
Configure AshTypescript in your config/config.exs file:
# config/config.exs
config :ash_typescript,
# File generation
output_file: "assets/js/ash_rpc.ts",
# RPC endpoints
run_endpoint: "/rpc/run",
validate_endpoint: "/rpc/validate",
# Dynamic endpoints (for separate frontend projects)
# run_endpoint: {:runtime_expr, "CustomTypes.getRunEndpoint()"},
# validate_endpoint: {:runtime_expr, "process.env.RPC_VALIDATE_ENDPOINT"},
# Field formatting
input_field_formatter: :camel_case,
output_field_formatter: :camel_case,
# Multitenancy
require_tenant_parameters: false,
# Lifecycle hooks (optional)
# rpc_action_before_request_hook: "RpcHooks.beforeActionRequest",
# rpc_action_after_request_hook: "RpcHooks.afterActionRequest",
# rpc_validation_before_request_hook: "RpcHooks.beforeValidationRequest",
# rpc_validation_after_request_hook: "RpcHooks.afterValidationRequest",
# Zod schema generation
generate_zod_schemas: true,
zod_import_path: "zod",
zod_schema_suffix: "ZodSchema",
# Validation functions
generate_validation_functions: true,
# Phoenix channel-based RPC actions
generate_phx_channel_rpc_actions: false,
phoenix_import_path: "phoenix",
# Phoenix channel lifecycle hooks (optional, requires generate_phx_channel_rpc_actions: true)
# rpc_action_before_channel_push_hook: "ChannelHooks.beforeChannelPush",
# rpc_action_after_channel_response_hook: "ChannelHooks.afterChannelResponse",
# rpc_validation_before_channel_push_hook: "ChannelHooks.beforeValidationChannelPush",
# rpc_validation_after_channel_response_hook: "ChannelHooks.afterValidationChannelResponse",
# Custom type imports
import_into_generated: [
%{
import_name: "CustomTypes",
file: "./customTypes"
}
],
# Type mapping overrides for dependency types
type_mapping_overrides: [
{AshUUID.UUID, "string"},
{SomeComplex.Custom.Type, "CustomTypes.MyCustomType"}
],
# TypeScript type for untyped maps
# untyped_map_type: "Record<string, any>" # Default - allows any value type
# untyped_map_type: "Record<string, unknown>" # Stricter - requires type checking
# RPC resource warnings
warn_on_missing_rpc_config: true,
warn_on_non_rpc_references: true| Option | Type | Default | Description |
|---|---|---|---|
output_file |
string |
"assets/js/ash_rpc.ts" |
Path where generated TypeScript code will be written |
run_endpoint |
string | {:runtime_expr, string} |
"/rpc/run" |
Endpoint for executing RPC actions |
validate_endpoint |
string | {:runtime_expr, string} |
"/rpc/validate" |
Endpoint for validating RPC requests |
input_field_formatter |
:camel_case | :snake_case |
:camel_case |
How to format field names in request inputs |
output_field_formatter |
:camel_case | :snake_case |
:camel_case |
How to format field names in response outputs |
require_tenant_parameters |
boolean |
false |
Whether to require tenant parameters in RPC calls |
rpc_action_before_request_hook |
string | nil |
nil |
Function called before RPC action requests |
rpc_action_after_request_hook |
string | nil |
nil |
Function called after RPC action requests |
rpc_validation_before_request_hook |
string | nil |
nil |
Function called before validation requests |
rpc_validation_after_request_hook |
string | nil |
nil |
Function called after validation requests |
rpc_action_hook_context_type |
string |
"Record<string, any>" |
TypeScript type for action hook context |
rpc_validation_hook_context_type |
string |
"Record<string, any>" |
TypeScript type for validation hook context |
generate_zod_schemas |
boolean |
true |
Whether to generate Zod validation schemas |
zod_import_path |
string |
"zod" |
Import path for Zod library |
zod_schema_suffix |
string |
"ZodSchema" |
Suffix for generated Zod schema names |
generate_validation_functions |
boolean |
true |
Whether to generate form validation functions |
generate_phx_channel_rpc_actions |
boolean |
false |
Whether to generate Phoenix channel-based RPC functions |
phoenix_import_path |
string |
"phoenix" |
Import path for Phoenix library |
rpc_action_before_channel_push_hook |
string | nil |
nil |
Function called before channel push for actions |
rpc_action_after_channel_response_hook |
string | nil |
nil |
Function called after channel response for actions |
rpc_validation_before_channel_push_hook |
string | nil |
nil |
Function called before channel push for validations |
rpc_validation_after_channel_response_hook |
string | nil |
nil |
Function called after channel response for validations |
rpc_action_channel_hook_context_type |
string |
"Record<string, any>" |
TypeScript type for channel action hook context |
rpc_validation_channel_hook_context_type |
string |
"Record<string, any>" |
TypeScript type for channel validation hook context |
import_into_generated |
list |
[] |
List of custom modules to import |
type_mapping_overrides |
list |
[] |
Override TypeScript types for Ash types |
untyped_map_type |
string |
"Record<string, any>" |
TypeScript type for untyped maps |
warn_on_missing_rpc_config |
boolean |
true |
Warn about resources with AshTypescript.Resource extension not in RPC config |
warn_on_non_rpc_references |
boolean |
true |
Warn about non-RPC resources referenced by RPC resources |
Configure RPC actions and typed queries in your domain modules:
defmodule MyApp.Domain do
use Ash.Domain, extensions: [AshTypescript.Rpc]
typescript_rpc do
resource MyApp.Todo do
# Standard CRUD actions
rpc_action :list_todos, :read
rpc_action :get_todo, :get
rpc_action :create_todo, :create
rpc_action :update_todo, :update
rpc_action :destroy_todo, :destroy
# Custom actions
rpc_action :complete_todo, :complete
rpc_action :archive_todo, :archive
# Typed queries for SSR and optimized data fetching
typed_query :dashboard_todos, :read do
ts_result_type_name "DashboardTodosResult"
ts_fields_const_name "dashboardTodosFields"
fields [
:id, :title, :priority, :status,
%{
user: [:name, :email],
comments: [:id, :content]
},
]
end
end
resource MyApp.User do
rpc_action :list_users, :read
rpc_action :get_user, :get
end
end
endEach rpc_action can be configured with:
- First argument - Name of the generated TypeScript function (e.g.,
:list_todos) - Second argument - Name of the Ash action to execute (e.g.,
:read)
Typed queries allow you to define pre-configured field selections with generated TypeScript types:
typed_query :dashboard_todos, :read do
ts_result_type_name "DashboardTodosResult"
ts_fields_const_name "dashboardTodosFields"
fields [
:id, :title, :priority, :status,
%{
user: [:name, :email],
comments: [:id, :content]
},
]
endOptions:
ts_result_type_name- Name for the generated result typets_fields_const_name- Name for the generated fields constantfields- Pre-configured field selection array
AshTypescript automatically converts field names between Elixir's snake_case convention and TypeScript's camelCase convention.
# Default: snake_case → camelCase
# user_name → userName
# created_at → createdAtconfig :ash_typescript,
input_field_formatter: :camel_case, # How inputs are formatted
output_field_formatter: :camel_case # How outputs are formattedAvailable formatters:
:camel_case- Converts to camelCase (e.g.,user_name→userName):snake_case- Keeps snake_case (e.g.,user_name→user_name)
For separate frontend projects or different deployment environments, AshTypescript supports dynamic endpoint configuration through runtime TypeScript expressions.
When building a separate frontend project (not embedded in your Phoenix app), you may need different backend endpoint URLs for:
- Development:
http://localhost:4000/rpc/run - Staging:
https://staging-api.myapp.com/rpc/run - Production:
https://api.myapp.com/rpc/run
Instead of hardcoding the endpoint in your Elixir config, you can use runtime expressions that will be evaluated at runtime in your TypeScript code.
You can use various runtime expressions depending on your needs:
# config/config.exs
config :ash_typescript,
# Option 1: Use environment variables directly (Node.js)
run_endpoint: {:runtime_expr, "process.env.RPC_RUN_ENDPOINT || '/rpc/run'"},
validate_endpoint: {:runtime_expr, "process.env.RPC_VALIDATE_ENDPOINT || '/rpc/validate'"},
# Option 2: Use Vite environment variables
# run_endpoint: {:runtime_expr, "import.meta.env.VITE_RPC_RUN_ENDPOINT || '/rpc/run'"},
# validate_endpoint: {:runtime_expr, "import.meta.env.VITE_RPC_VALIDATE_ENDPOINT || '/rpc/validate'"},
# Option 3: Use custom functions from imported modules
# run_endpoint: {:runtime_expr, "MyAppConfig.getRunEndpoint()"},
# validate_endpoint: {:runtime_expr, "MyAppConfig.getValidateEndpoint()"},
# Option 4: Use complex expressions with conditionals
# run_endpoint: {:runtime_expr, "window.location.hostname === 'localhost' ? 'http://localhost:4000/rpc/run' : '/rpc/run'"},
# Import modules if needed for custom functions (Option 3)
# import_into_generated: [
# %{
# import_name: "MyAppConfig",
# file: "./myAppConfig"
# }
# ]# .env.local
RPC_RUN_ENDPOINT=http://localhost:4000/rpc/run
RPC_VALIDATE_ENDPOINT=http://localhost:4000/rpc/validate
# .env.production
RPC_RUN_ENDPOINT=https://api.myapp.com/rpc/run
RPC_VALIDATE_ENDPOINT=https://api.myapp.com/rpc/validateGenerated TypeScript will use the environment variables directly:
const response = await fetchFunction(process.env.RPC_RUN_ENDPOINT || '/rpc/run', fetchOptions);# .env.development
VITE_RPC_RUN_ENDPOINT=http://localhost:4000/rpc/run
# .env.production
VITE_RPC_RUN_ENDPOINT=https://api.myapp.com/rpc/runGenerated TypeScript:
const response = await fetchFunction(import.meta.env.VITE_RPC_RUN_ENDPOINT || '/rpc/run', fetchOptions);Create a TypeScript file with functions that return the appropriate endpoints:
// myAppConfig.ts
export function getRunEndpoint(): string {
// Use environment variables from your frontend build system
const baseUrl = import.meta.env.VITE_API_URL || "http://localhost:4000";
return `${baseUrl}/rpc/run`;
}
export function getValidateEndpoint(): string {
const baseUrl = import.meta.env.VITE_API_URL || "http://localhost:4000";
return `${baseUrl}/rpc/validate`;
}
// For different environments:
// Development: VITE_API_URL=http://localhost:4000
// Staging: VITE_API_URL=https://staging-api.myapp.com
// Production: VITE_API_URL=https://api.myapp.comFor browser-based applications that need different endpoints based on hostname:
config :ash_typescript,
run_endpoint: {:runtime_expr, """
(window.location.hostname === 'localhost'
? 'http://localhost:4000/rpc/run'
: `https://${window.location.hostname}/rpc/run`)
"""}This allows dynamic endpoint resolution based on the current page's hostname.
The generated RPC functions will use your runtime expressions directly in the code:
// Example 1: With environment variables
// config: run_endpoint: {:runtime_expr, "process.env.RPC_RUN_ENDPOINT || '/rpc/run'"}
export async function createTodo<Fields extends CreateTodoFields>(
config: CreateTodoConfig<Fields>
): Promise<CreateTodoResult<Fields>> {
// Runtime expression is embedded directly
const response = await fetchFunction(
process.env.RPC_RUN_ENDPOINT || '/rpc/run',
fetchOptions
);
// ... rest of implementation
}// Example 2: With custom function
// config: run_endpoint: {:runtime_expr, "MyAppConfig.getRunEndpoint()"}
import * as MyAppConfig from "./myAppConfig";
export async function createTodo<Fields extends CreateTodoFields>(
config: CreateTodoConfig<Fields>
): Promise<CreateTodoResult<Fields>> {
// Custom function is called at runtime
const response = await fetchFunction(
MyAppConfig.getRunEndpoint(),
fetchOptions
);
// ... rest of implementation
}AshTypescript provides lifecycle hooks that allow you to inject custom logic before and after HTTP requests and Phoenix Channel pushes. These hooks enable cross-cutting concerns like authentication, logging, telemetry, performance tracking, and error monitoring.
Lifecycle hooks provide a centralized way to:
- Add authentication tokens - Inject auth headers for all requests
- Log requests and responses - Track API calls for debugging
- Measure performance - Time API calls and track latency
- Send telemetry - Report metrics to monitoring services
- Handle errors globally - Track errors in Sentry, Datadog, etc.
- Transform requests - Modify config before sending
Configure lifecycle hooks for HTTP-based RPC actions:
# config/config.exs
config :ash_typescript,
# HTTP lifecycle hooks for RPC actions
rpc_action_before_request_hook: "RpcHooks.beforeActionRequest",
rpc_action_after_request_hook: "RpcHooks.afterActionRequest",
# HTTP lifecycle hooks for validation actions
rpc_validation_before_request_hook: "RpcHooks.beforeValidationRequest",
rpc_validation_after_request_hook: "RpcHooks.afterValidationRequest",
# TypeScript types for hook context (optional)
rpc_action_hook_context_type: "RpcHooks.ActionHookContext",
rpc_validation_hook_context_type: "RpcHooks.ValidationHookContext",
# Import the module containing your hook functions
import_into_generated: [
%{
import_name: "RpcHooks",
file: "./rpcHooks"
}
]| Option | Type | Default | Description |
|---|---|---|---|
rpc_action_before_request_hook |
string | nil |
nil |
Function called before RPC action requests |
rpc_action_after_request_hook |
string | nil |
nil |
Function called after RPC action requests |
rpc_validation_before_request_hook |
string | nil |
nil |
Function called before validation requests |
rpc_validation_after_request_hook |
string | nil |
nil |
Function called after validation requests |
rpc_action_hook_context_type |
string |
"Record<string, any>" |
TypeScript type for action hook context |
rpc_validation_hook_context_type |
string |
"Record<string, any>" |
TypeScript type for validation hook context |
For Phoenix Channel-based RPC actions, configure channel-specific hooks. Like HTTP hooks, channel hooks are separated between actions and validations:
config :ash_typescript,
# Enable channel RPC generation
generate_phx_channel_rpc_actions: true,
# Channel lifecycle hooks for RPC actions
rpc_action_before_channel_push_hook: "ChannelHooks.beforeChannelPush",
rpc_action_after_channel_response_hook: "ChannelHooks.afterChannelResponse",
# Channel lifecycle hooks for validation actions
rpc_validation_before_channel_push_hook: "ChannelHooks.beforeValidationChannelPush",
rpc_validation_after_channel_response_hook: "ChannelHooks.afterValidationChannelResponse",
# Channel hook context types (optional)
rpc_action_channel_hook_context_type: "ChannelHooks.ChannelActionHookContext",
rpc_validation_channel_hook_context_type: "ChannelHooks.ChannelValidationHookContext",
# Import the module containing channel hooks
import_into_generated: [
%{
import_name: "ChannelHooks",
file: "./channelHooks"
}
]| Option | Type | Default | Description |
|---|---|---|---|
rpc_action_before_channel_push_hook |
string | nil |
nil |
Function called before channel push for actions |
rpc_action_after_channel_response_hook |
string | nil |
nil |
Function called after channel response for actions |
rpc_validation_before_channel_push_hook |
string | nil |
nil |
Function called before channel push for validations |
rpc_validation_after_channel_response_hook |
string | nil |
nil |
Function called after channel response for validations |
rpc_action_channel_hook_context_type |
string |
"Record<string, any>" |
TypeScript type for channel action hook context |
rpc_validation_channel_hook_context_type |
string |
"Record<string, any>" |
TypeScript type for channel validation hook context |
// rpcHooks.ts
export interface ActionHookContext {
enableLogging?: boolean;
enableTiming?: boolean;
customHeaders?: Record<string, string>;
}
export async function beforeActionRequest<T>(
action: string,
config: T & { hookCtx?: ActionHookContext }
): Promise<T & { hookCtx?: ActionHookContext }> {
const startTime = performance.now();
if (config.hookCtx?.enableLogging) {
console.log(`[${action}] Request started`, config);
}
// Add auth token
const token = localStorage.getItem('authToken');
const headers = {
...config.headers,
...config.hookCtx?.customHeaders,
...(token && { 'Authorization': `Bearer ${token}` })
};
return {
...config,
headers,
hookCtx: {
...config.hookCtx,
startTime
}
};
}
export async function afterActionRequest<T>(
action: string,
config: T & { hookCtx?: ActionHookContext },
result: any
): Promise<any> {
if (config.hookCtx?.enableTiming) {
const duration = performance.now() - (config.hookCtx as any).startTime;
console.log(`[${action}] Completed in ${duration}ms`);
}
return result;
}For complete details and examples, see the Lifecycle Hooks documentation.
TypeScript has stricter identifier rules than Elixir. AshTypescript provides built-in verification and mapping for invalid field and argument names.
AshTypescript detects and requires mapping for these patterns:
- Underscores before digits:
field_1,address_line_2,item__3 - Question marks:
is_active?,enabled?
Map invalid field names using the field_names option in your resource's typescript block:
defmodule MyApp.User do
use Ash.Resource,
domain: MyApp.Domain,
extensions: [AshTypescript.Resource]
typescript do
type_name "User"
# Map invalid field names to valid TypeScript identifiers
field_names [
address_line_1: :address_line1,
address_line_2: :address_line2,
is_active?: :is_active
]
end
attributes do
attribute :name, :string, public?: true
attribute :address_line_1, :string, public?: true
attribute :address_line_2, :string, public?: true
attribute :is_active?, :boolean, public?: true
end
endGenerated TypeScript:
// Input (create/update)
const user = await createUser({
input: {
name: "John",
addressLine1: "123 Main St", // Mapped from address_line_1
addressLine2: "Apt 4B", // Mapped from address_line_2
isActive: true // Mapped from is_active?
},
fields: ["id", "name", "addressLine1", "addressLine2", "isActive"]
});
// Output - same mapped names
if (result.success) {
console.log(result.data.addressLine1); // "123 Main St"
console.log(result.data.isActive); // true
}Map invalid action argument names using the argument_names option:
typescript do
type_name "Todo"
argument_names [
search: [query_string_1: :query_string1],
filter_todos: [is_completed?: :is_completed]
]
end
actions do
read :search do
argument :query_string_1, :string
end
read :filter_todos do
argument :is_completed?, :boolean
end
endGenerated TypeScript:
// Arguments use mapped names
const results = await searchTodos({
input: { queryString1: "urgent tasks" }, // Mapped from query_string_1
fields: ["id", "title"]
});
const filtered = await filterTodos({
input: { isCompleted: false }, // Mapped from is_completed?
fields: ["id", "title"]
});For invalid field names in map/keyword/tuple type constraints, create a custom Ash.Type.NewType with the typescript_field_names/0 callback:
# Define custom type with field mapping
defmodule MyApp.CustomMetadata do
use Ash.Type.NewType,
subtype_of: :map,
constraints: [
fields: [
field_1: [type: :string],
is_active?: [type: :boolean],
line_2: [type: :string]
]
]
@impl true
def typescript_field_names do
[
field_1: :field1,
is_active?: :isActive,
line_2: :line2
]
end
end
# Use custom type in resource
defmodule MyApp.Resource do
use Ash.Resource,
domain: MyApp.Domain,
extensions: [AshTypescript.Resource]
typescript do
type_name "Resource"
end
attributes do
attribute :metadata, MyApp.CustomMetadata, public?: true
end
endGenerated TypeScript:
type Resource = {
metadata: {
field1: string; // Mapped from field_1
isActive: boolean; // Mapped from is_active?
line2: string; // Mapped from line_2
}
}AshTypescript includes three verifiers that check for invalid names at compile time:
Resource field verification error:
Invalid field names found that contain question marks, or numbers preceded by underscores.
Invalid field names in resource MyApp.User:
- attribute address_line_1 → address_line1
- attribute is_active? → is_active
You can use field_names in the typescript section to provide valid alternatives.
Map constraint verification error:
Invalid field names found in map/keyword/tuple type constraints.
Invalid constraint field names in attribute :metadata on resource MyApp.Resource:
- field_1 → field1
- is_active? → is_active
To fix this, create a custom Ash.Type.NewType using map/keyword/tuple as a subtype,
and define the `typescript_field_names/0` callback to map invalid field names to valid ones.
Create custom Ash types with TypeScript integration:
# 1. Create custom type in Elixir
defmodule MyApp.PriorityScore do
use Ash.Type
def storage_type(_), do: :integer
def cast_input(value, _) when is_integer(value) and value >= 1 and value <= 100, do: {:ok, value}
def cast_input(_, _), do: {:error, "must be integer 1-100"}
def cast_stored(value, _), do: {:ok, value}
def dump_to_native(value, _), do: {:ok, value}
def apply_constraints(value, _), do: {:ok, value}
# AshTypescript integration
def typescript_type_name, do: "CustomTypes.PriorityScore"
end// 2. Create TypeScript type definitions in customTypes.ts
export type PriorityScore = number;
export type ColorPalette = {
primary: string;
secondary: string;
accent: string;
};# 3. Use in your resources
defmodule MyApp.Todo do
use Ash.Resource, domain: MyApp.Domain
attributes do
uuid_primary_key :id
attribute :title, :string, public?: true
attribute :priority_score, MyApp.PriorityScore, public?: true
end
endThe generated TypeScript will automatically include your custom types:
// Generated TypeScript includes imports
import * as CustomTypes from "./customTypes";
// Your resource types use the custom types
interface TodoFieldsSchema {
id: string;
title: string;
priorityScore?: CustomTypes.PriorityScore | null;
}When using custom Ash types from dependencies (where you can't add the typescript_type_name/0 callback), use the type_mapping_overrides configuration to map them to TypeScript types.
# config/config.exs
config :ash_typescript,
type_mapping_overrides: [
{AshUUID.UUID, "string"},
{SomeComplex.Custom.Type, "CustomTypes.MyCustomType"}
]# Suppose you're using a third-party library with a custom type
defmodule MyApp.Product do
use Ash.Resource,
domain: MyApp.Domain,
extensions: [AshTypescript.Resource]
typescript do
type_name "Product"
end
attributes do
uuid_primary_key :id
attribute :name, :string, public?: true
# Type from a dependency - can't modify it to add typescript_type_name
attribute :uuid, AshUUID.UUID, public?: true
attribute :some_value, SomeComplex.Custom.Type, public?: true
end
end# Configure the type mappings
config :ash_typescript,
type_mapping_overrides: [
# Map to built-in TypeScript type
{AshUUID.UUID, "string"},
# Map to custom type (requires defining the type in customTypes.ts)
{SomeComplex.Custom.Type, "CustomTypes.MyCustomType"}
],
# Import your custom types
import_into_generated: [
%{
import_name: "CustomTypes",
file: "./customTypes"
}
]// customTypes.ts - Define the MyCustomType type
export type MyCustomType = {
someField: string;
anotherField: number;
};Generated TypeScript:
import * as CustomTypes from "./customTypes";
interface ProductResourceSchema {
id: string;
name: string;
uuid: string; // Mapped to built-in string type
someValue: CustomTypes.MyCustomType; // Mapped to custom type
}- ✅ Third-party Ash types from dependencies you don't control
- ✅ Library types like
AshUUID.UUID, etc. - ❌ Your own types - prefer using
typescript_type_name/0callback instead
Import custom TypeScript modules into the generated code:
config :ash_typescript,
import_into_generated: [
%{
import_name: "CustomTypes",
file: "./customTypes"
},
%{
import_name: "MyAppConfig",
file: "./myAppConfig"
}
]This generates:
import * as CustomTypes from "./customTypes";
import * as MyAppConfig from "./myAppConfig";| Option | Type | Description |
|---|---|---|
import_name |
string |
Name to use for the import (e.g., CustomTypes) |
file |
string |
Relative path to the module file (e.g., ./customTypes) |
By default, AshTypescript generates Record<string, any> for map-like types without field constraints. You can configure this to use stricter types like Record<string, unknown> for better type safety.
# config/config.exs
config :ash_typescript,
# Default - allows any value type (more permissive)
untyped_map_type: "Record<string, any>"
# Stricter - requires type checking before use (recommended for new projects)
# untyped_map_type: "Record<string, unknown>"
# Custom - use your own type definition
# untyped_map_type: "MyCustomMapType"This configuration applies to all map-like types without field constraints:
Ash.Type.MapwithoutfieldsconstraintAsh.Type.KeywordwithoutfieldsconstraintAsh.Type.TuplewithoutfieldsconstraintAsh.Type.Structwithoutinstance_oforfieldsconstraint
Maps with field constraints are NOT affected and will still generate typed objects.
With Record<string, any> (default):
// More permissive - values can be used directly
const todo = await getTodo({ fields: ["id", "customData"] });
if (todo.success && todo.data.customData) {
const value = todo.data.customData.someField; // OK - no error
console.log(value.toUpperCase()); // Runtime error if not a string!
}With Record<string, unknown> (stricter):
// Stricter - requires type checking before use
const todo = await getTodo({ fields: ["id", "customData"] });
if (todo.success && todo.data.customData) {
const value = todo.data.customData.someField; // Type: unknown
console.log(value.toUpperCase()); // ❌ TypeScript error!
// Must check type first
if (typeof value === 'string') {
console.log(value.toUpperCase()); // ✅ OK
}
}defmodule MyApp.Todo do
use Ash.Resource,
domain: MyApp.Domain,
extensions: [AshTypescript.Resource]
attributes do
# Untyped map - uses configured untyped_map_type
attribute :custom_data, :map, public?: true
# Typed map - always generates typed object (not affected by config)
attribute :metadata, :map, public?: true, constraints: [
fields: [
priority: [type: :string],
tags: [type: {:array, :string}]
]
]
end
endGenerated TypeScript:
// With untyped_map_type: "Record<string, unknown>"
type TodoResourceSchema = {
customData: Record<string, unknown> | null; // Uses configured type
metadata: { // Typed object (not affected)
priority: string;
tags: Array<string>;
} | null;
}Use Record<string, any> when:
- You need maximum flexibility
- You're working with truly dynamic data structures
- You trust your backend data and want faster development
- Backward compatibility with existing code is important
Use Record<string, unknown> when:
- You want maximum type safety
- You're starting a new project
- You want to catch potential runtime errors at compile time
- You prefer explicit type checking over implicit assumptions
AshTypescript can generate Zod validation schemas for runtime type validation.
config :ash_typescript,
# Enable/disable Zod schema generation
generate_zod_schemas: true,
# Import path for Zod library
zod_import_path: "zod",
# Suffix for generated schema names
zod_schema_suffix: "ZodSchema"| Option | Type | Default | Description |
|---|---|---|---|
generate_zod_schemas |
boolean |
true |
Whether to generate Zod validation schemas |
zod_import_path |
string |
"zod" |
Import path for Zod library |
zod_schema_suffix |
string |
"ZodSchema" |
Suffix appended to schema names |
When enabled, generates schemas like:
import { z } from "zod";
export const TodoZodSchema = z.object({
id: z.string(),
title: z.string(),
completed: z.boolean().nullable()
});AshTypescript can generate Phoenix channel-based RPC functions alongside HTTP-based functions.
config :ash_typescript,
# Enable Phoenix channel RPC action generation
generate_phx_channel_rpc_actions: true,
# Import path for Phoenix library
phoenix_import_path: "phoenix"| Option | Type | Default | Description |
|---|---|---|---|
generate_phx_channel_rpc_actions |
boolean |
false |
Whether to generate channel-based RPC functions |
phoenix_import_path |
string |
"phoenix" |
Import path for Phoenix library |
When enabled, generates both HTTP and channel-based functions:
import { Channel } from "phoenix";
// HTTP-based (always available)
export async function listTodos<Fields extends ListTodosFields>(
config: ListTodosConfig<Fields>
): Promise<ListTodosResult<Fields>> {
// ... HTTP implementation
}
// Channel-based (when enabled)
export function listTodosChannel<Fields extends ListTodosFields>(
config: ListTodosChannelConfig<Fields>
): void {
// ... Channel implementation
}For more details on using Phoenix channels, see the Phoenix Channels topic documentation.
AshTypescript provides compile-time warnings to help you identify potential configuration issues with your RPC resources. These warnings appear during mix compile or when running mix test.codegen.
This warning appears when you have resources with the AshTypescript.Resource extension that are not configured in any domain's typescript_rpc block.
Example warning:
⚠️ Found resources with AshTypescript.Resource extension
but not listed in any domain's typescript_rpc block:
• MyApp.ForgottenResource
• MyApp.AnotherResource
These resources will not have TypeScript types generated.
To fix this, add them to a domain's typescript_rpc block:
defmodule MyApp.Domain do
use Ash.Domain, extensions: [AshTypescript.Rpc]
typescript_rpc do
resource MyApp.ForgottenResource do
rpc_action :list, :read
end
end
end
When this appears:
- You added
extensions: [AshTypescript.Resource]to a resource - The resource is not listed in any
typescript_rpcblock in your domains - The resource is not an embedded resource (embedded resources are automatically discovered)
To fix:
- Add the resource to a domain's
typescript_rpcblock, OR - Remove
AshTypescript.Resourceextension if the resource doesn't need TypeScript types, OR - Disable the warning (see configuration below)
This warning appears when RPC resources reference other resources that are not themselves configured as RPC resources.
Example warning:
⚠️ Found non-RPC resources referenced by RPC resources:
• MyApp.InternalResource
Referenced from:
- Todo -> metadata -> TodoMetadata -> internal
- User -> profile_data
• MyApp.Helper
Referenced from:
- Todo -> helper_data
These resources are referenced in attributes, calculations, or aggregates
of RPC resources, but are not themselves configured as RPC resources.
They will NOT have TypeScript types or RPC functions generated.
If these resources should be accessible via RPC, add them to a domain's
typescript_rpc block. Otherwise, you can ignore this warning.
When this appears:
- An RPC resource has an attribute, calculation, or aggregate whose type references another resource
- The referenced resource is not configured in any
typescript_rpcblock - The referenced resource is not an embedded resource
To fix:
- Add the referenced resource to a domain's
typescript_rpcblock if it should be accessible, OR - Leave it as-is if the resource is intentionally internal-only, OR
- Disable the warning (see configuration below)
Both warnings can be independently disabled in your configuration:
# config/config.exs
config :ash_typescript,
# Disable warning about resources with extension but not in RPC config
warn_on_missing_rpc_config: false,
# Disable warning about non-RPC resources referenced by RPC resources
warn_on_non_rpc_references: false| Option | Type | Default | Description |
|---|---|---|---|
warn_on_missing_rpc_config |
boolean |
true |
Warn about resources with AshTypescript.Resource extension that are not configured in any typescript_rpc block |
warn_on_non_rpc_references |
boolean |
true |
Warn about non-RPC resources that are referenced by RPC resources (in attributes, calculations, or aggregates) |
Disable warn_on_missing_rpc_config when:
- You intentionally have resources with the extension that you don't want in the RPC config yet
- You're gradually migrating resources to RPC and don't want warnings during the transition
- You use the extension for other purposes besides RPC generation
Disable warn_on_non_rpc_references when:
- You have many internal resources that are referenced but intentionally not exposed via RPC
- The warning noise outweighs the benefits for your use case
- You have a clear convention for which resources should be RPC-accessible
Best practice: Keep warnings enabled during development and only disable them if you have a specific reason. They help catch configuration mistakes early.
- Getting Started Tutorial - Initial setup and basic usage
- Mix Tasks Reference - Code generation commands
- Phoenix Channels - Channel-based RPC actions
- Troubleshooting Reference - Common problems and solutions