Configuration Reference

View Source

This document provides a comprehensive reference for all AshTypescript configuration options.

Application Configuration

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

Configuration Options

OptionTypeDefaultDescription
output_filestring"assets/js/ash_rpc.ts"Path where generated TypeScript code will be written
run_endpointstring | {:runtime_expr, string}"/rpc/run"Endpoint for executing RPC actions
validate_endpointstring | {:runtime_expr, string}"/rpc/validate"Endpoint for validating RPC requests
input_field_formatter:camel_case | :snake_case:camel_caseHow to format field names in request inputs
output_field_formatter:camel_case | :snake_case:camel_caseHow to format field names in response outputs
require_tenant_parametersbooleanfalseWhether to require tenant parameters in RPC calls
rpc_action_before_request_hookstring | nilnilFunction called before RPC action requests
rpc_action_after_request_hookstring | nilnilFunction called after RPC action requests
rpc_validation_before_request_hookstring | nilnilFunction called before validation requests
rpc_validation_after_request_hookstring | nilnilFunction called after validation requests
rpc_action_hook_context_typestring"Record<string, any>"TypeScript type for action hook context
rpc_validation_hook_context_typestring"Record<string, any>"TypeScript type for validation hook context
generate_zod_schemasbooleantrueWhether to generate Zod validation schemas
zod_import_pathstring"zod"Import path for Zod library
zod_schema_suffixstring"ZodSchema"Suffix for generated Zod schema names
generate_validation_functionsbooleantrueWhether to generate form validation functions
generate_phx_channel_rpc_actionsbooleanfalseWhether to generate Phoenix channel-based RPC functions
phoenix_import_pathstring"phoenix"Import path for Phoenix library
rpc_action_before_channel_push_hookstring | nilnilFunction called before channel push for actions
rpc_action_after_channel_response_hookstring | nilnilFunction called after channel response for actions
rpc_validation_before_channel_push_hookstring | nilnilFunction called before channel push for validations
rpc_validation_after_channel_response_hookstring | nilnilFunction called after channel response for validations
rpc_action_channel_hook_context_typestring"Record<string, any>"TypeScript type for channel action hook context
rpc_validation_channel_hook_context_typestring"Record<string, any>"TypeScript type for channel validation hook context
import_into_generatedlist[]List of custom modules to import
type_mapping_overrideslist[]Override TypeScript types for Ash types
untyped_map_typestring"Record<string, any>"TypeScript type for untyped maps

Domain Configuration

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
end

RPC Action Configuration

Each 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 Query Configuration

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]
    },
  ]
end

Options:

  • ts_result_type_name - Name for the generated result type
  • ts_fields_const_name - Name for the generated fields constant
  • fields - Pre-configured field selection array

Field Formatting

AshTypescript automatically converts field names between Elixir's snake_case convention and TypeScript's camelCase convention.

Default Behavior

# Default: snake_case → camelCase
# user_name → userName
# created_at → createdAt

Configuration Options

config :ash_typescript,
  input_field_formatter: :camel_case,   # How inputs are formatted
  output_field_formatter: :camel_case   # How outputs are formatted

Available formatters:

  • :camel_case - Converts to camelCase (e.g., user_nameuserName)
  • :snake_case - Keeps snake_case (e.g., user_nameuser_name)

Dynamic RPC Endpoints

For separate frontend projects or different deployment environments, AshTypescript supports dynamic endpoint configuration through runtime TypeScript expressions.

Why Use Dynamic Endpoints?

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.

Configuration Options

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"
  #   }
  # ]

Usage Examples

Option 1: Environment Variables (Node.js/Next.js)

# .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/validate

Generated TypeScript will use the environment variables directly:

const response = await fetchFunction(process.env.RPC_RUN_ENDPOINT || '/rpc/run', fetchOptions);

Option 2: Vite Environment Variables

# .env.development
VITE_RPC_RUN_ENDPOINT=http://localhost:4000/rpc/run

# .env.production
VITE_RPC_RUN_ENDPOINT=https://api.myapp.com/rpc/run

Generated TypeScript:

const response = await fetchFunction(import.meta.env.VITE_RPC_RUN_ENDPOINT || '/rpc/run', fetchOptions);

Option 3: Custom Functions

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.com

Option 4: Complex Conditional Expressions

For 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.

Generated Code

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
}

Lifecycle Hooks Configuration

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.

Why Use Lifecycle Hooks?

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

Configuration

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"
    }
  ]

Configuration Options

OptionTypeDefaultDescription
rpc_action_before_request_hookstring | nilnilFunction called before RPC action requests
rpc_action_after_request_hookstring | nilnilFunction called after RPC action requests
rpc_validation_before_request_hookstring | nilnilFunction called before validation requests
rpc_validation_after_request_hookstring | nilnilFunction called after validation requests
rpc_action_hook_context_typestring"Record<string, any>"TypeScript type for action hook context
rpc_validation_hook_context_typestring"Record<string, any>"TypeScript type for validation hook context

Phoenix Channel Lifecycle Hooks

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"
    }
  ]

Channel Hook Options

OptionTypeDefaultDescription
rpc_action_before_channel_push_hookstring | nilnilFunction called before channel push for actions
rpc_action_after_channel_response_hookstring | nilnilFunction called after channel response for actions
rpc_validation_before_channel_push_hookstring | nilnilFunction called before channel push for validations
rpc_validation_after_channel_response_hookstring | nilnilFunction called after channel response for validations
rpc_action_channel_hook_context_typestring"Record<string, any>"TypeScript type for channel action hook context
rpc_validation_channel_hook_context_typestring"Record<string, any>"TypeScript type for channel validation hook context

Example Hook Implementation

// 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.

Field and Argument Name Mapping

TypeScript has stricter identifier rules than Elixir. AshTypescript provides built-in verification and mapping for invalid field and argument names.

Invalid Name Patterns

AshTypescript detects and requires mapping for these patterns:

  • Underscores before digits: field_1, address_line_2, item__3
  • Question marks: is_active?, enabled?

Resource Field Mapping

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
end

Generated 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
}

Action Argument Mapping

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
end

Generated 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"]
});

Map Type Field Mapping

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
end

Generated TypeScript:

type Resource = {
  metadata: {
    field1: string;      // Mapped from field_1
    isActive: boolean;   // Mapped from is_active?
    line2: string;       // Mapped from line_2
  }
}

Verification and Error Messages

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.

Custom Types

Create custom Ash types with TypeScript integration:

Basic Custom Type

# 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
end

The 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;
}

Type Mapping Overrides

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.

Configuration

# config/config.exs
config :ash_typescript,
  type_mapping_overrides: [
    {AshUUID.UUID, "string"},
    {SomeComplex.Custom.Type, "CustomTypes.MyCustomType"}
  ]

Example: Mapping Dependency Types

# 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
}

When to Use Type Mapping Overrides

  • Third-party Ash types from dependencies you don't control
  • Library types like AshUUID.UUID, etc.
  • Your own types - prefer using typescript_type_name/0 callback instead

Custom Type Imports

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";

Import Configuration Options

OptionTypeDescription
import_namestringName to use for the import (e.g., CustomTypes)
filestringRelative path to the module file (e.g., ./customTypes)

Untyped Map Type Configuration

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.

Configuration

# 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"

What Gets Affected

This configuration applies to all map-like types without field constraints:

Maps with field constraints are NOT affected and will still generate typed objects.

Type Safety Comparison

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
  }
}

Example Resources

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
end

Generated 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;
}

When to Use Each Option

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

Zod Schema Configuration

AshTypescript can generate Zod validation schemas for runtime type validation.

Configuration

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"

Configuration Options

OptionTypeDefaultDescription
generate_zod_schemasbooleantrueWhether to generate Zod validation schemas
zod_import_pathstring"zod"Import path for Zod library
zod_schema_suffixstring"ZodSchema"Suffix appended to schema names

Generated Output

When enabled, generates schemas like:

import { z } from "zod";

export const TodoZodSchema = z.object({
  id: z.string(),
  title: z.string(),
  completed: z.boolean().nullable()
});

Phoenix Channel Configuration

AshTypescript can generate Phoenix channel-based RPC functions alongside HTTP-based functions.

Configuration

config :ash_typescript,
  # Enable Phoenix channel RPC action generation
  generate_phx_channel_rpc_actions: true,

  # Import path for Phoenix library
  phoenix_import_path: "phoenix"

Configuration Options

OptionTypeDefaultDescription
generate_phx_channel_rpc_actionsbooleanfalseWhether to generate channel-based RPC functions
phoenix_import_pathstring"phoenix"Import path for Phoenix library

Generated Output

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.

See Also