π₯ Automatic TypeScript type generation for Ash resources and actions
Generate type-safe TypeScript clients directly from your Elixir Ash resources, ensuring end-to-end type safety between your backend and frontend. Never write API types manually again.
Get up and running in under 5 minutes:
Add to your mix.exs
:
def deps do
[
{:ash_typescript, "~> 0.1.2"}
]
end
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 :get_todo, :get
end
end
resources do
resource MyApp.Todo
end
end
defmodule MyAppWeb.RpcController do
use MyAppWeb, :controller
def run(conn, params) do
# Actor (and tenant if needed) must be set on the conn before calling run/2 or validate/2
# If your pipeline does not set these, you must add something like the following code:
# conn = Ash.PlugHelpers.set_actor(conn, conn.assigns[:current_user])
# conn = Ash.PlugHelpers.set_tenant(conn, conn.assigns[:tenant])
result = AshTypescript.Rpc.run_action(:my_app, conn, params)
json(conn, result)
end
def validate(conn, params) do
result = AshTypescript.Rpc.validate_action(:my_app, conn, params)
json(conn, result)
end
end
Add these routes to your router.ex
to map the RPC endpoints:
scope "/rpc", MyAppWeb do
pipe_through :api # or :browser if using session-based auth
post "/run", RpcController, :run
post "/validate", RpcController, :validate
end
Recommended approach (runs codegen for all Ash extensions in your project):
mix ash.codegen --dev"
Alternative approach (runs codegen only for AshTypescript):
mix ash_typescript.codegen --output "assets/js/ash_rpc.ts"
import { listTodos, createTodo } from './ash_rpc';
// β
Fully type-safe API calls
const todos = await listTodos({
fields: ["id", "title", "completed"],
filter: { completed: false }
});
const newTodo = await createTodo({
fields: ["id", "title", { user: ["name", "email"] }],
input: { title: "Learn AshTypescript", priority: "high" }
});
π That's it! Your TypeScript frontend now has compile-time type safety for your Elixir backend.
- π₯ Zero-config TypeScript generation - Automatically generates types from Ash resources
- π‘οΈ End-to-end type safety - Catch integration errors at compile time, not runtime
- β‘ Smart field selection - Request only needed fields with full type inference
- π― RPC client generation - Type-safe function calls for all action types
- π’ Multitenancy ready - Automatic tenant parameter handling
- π¦ Advanced type support - Enums, unions, embedded resources, and calculations
- π§ Highly configurable - Custom endpoints, formatting, and output options
- π§ͺ Runtime validation - Zod schemas for runtime type checking and form validation
- π Auto-generated filters - Type-safe filtering with comprehensive operator support
- π Form validation - Client-side validation functions for all actions
- π― Typed queries - Pre-configured queries for SSR and optimized data fetching
- π¨ Flexible field formatting - Separate input/output formatters (camelCase, snake_case, etc.)
- π Custom HTTP clients - Support for custom fetch functions and request options (axios, interceptors, etc.)
- Installation
- Quick Start
- Core Concepts
- Usage Examples
- Advanced Features
- Configuration
- Mix Tasks
- API Reference
- Requirements
- Troubleshooting
- Contributing
- License
- Resource Definition: Define your Ash resources with attributes, relationships, and actions
- RPC Configuration: Expose specific actions through your domain's RPC configuration
- Type Generation: Run
mix ash_typescript.codegen
to generate TypeScript types - Frontend Integration: Import and use fully type-safe client functions
- Compile-time validation - TypeScript compiler catches API misuse
- Autocomplete support - Full IntelliSense for all resource fields and actions
- Refactoring safety - Rename fields in Elixir, get TypeScript errors immediately
- Documentation - Generated types serve as living API documentation
import { listTodos, getTodo, createTodo, updateTodo, destroyTodo } from './ash_rpc';
// List todos with field selection
const todos = await listTodos({
fields: ["id", "title", "completed", "priority"],
filter: { status: "active" },
sort: "-priority,+createdAt"
});
// Get single todo with relationships
const todo = await getTodo({
fields: ["id", "title", { user: ["name", "email"] }],
id: "todo-123"
});
// Create new todo
const newTodo = await createTodo({
fields: ["id", "title", "createdAt"],
input: {
title: "Learn AshTypescript",
priority: "high",
dueDate: "2024-01-01"
}
});
// Update existing todo (primary key separate from input)
const updatedTodo = await updateTodo({
fields: ["id", "title", "priority", "updatedAt"],
primaryKey: "todo-123", // Primary key as separate parameter
input: {
title: "Updated: Learn AshTypescript",
priority: "urgent"
}
});
// Delete todo (primary key separate from input)
const deletedTodo = await destroyTodo({
fields: [],
primaryKey: "todo-123" // Primary key as separate parameter
});
// Complex nested field selection
const todoWithDetails = await getTodo({
fields: [
"id", "title", "description",
{
user: ["name", "email", "avatarUrl"],
comments: ["id", "text", { author: ["name"] }],
tags: ["name", "color"]
}
],
id: "todo-123"
});
// Calculations with arguments
const todoWithCalc = await getTodo({
fields: [
"id", "title",
{
"priorityScore": {
"args": { "multiplier": 2 },
"fields": ["score", "rank"]
}
}
],
id: "todo-123"
});
All generated RPC functions return a {success: true/false}
structure instead of throwing exceptions:
const result = await createTodo({
fields: ["id", "title"],
input: { title: "New Todo" }
});
if (result.success) {
// Access the created todo
console.log("Created todo:", result.data);
const todoId: string = result.data.id;
const todoTitle: string = result.data.title;
} else {
// Handle validation errors, network errors, etc.
result.errors.forEach(error => {
console.error(`Error: ${error.message}`);
if (error.fieldPath) {
console.error(`Field: ${error.fieldPath}`);
}
});
}
import { listTodos, buildCSRFHeaders } from './ash_rpc';
// With CSRF protection
const todos = await listTodos({
fields: ["id", "title"],
headers: buildCSRFHeaders()
});
// With custom authentication
const todos = await listTodos({
fields: ["id", "title"],
headers: {
"Authorization": "Bearer your-token-here",
"X-Custom-Header": "value"
}
});
AshTypescript allows you to customize the HTTP client used for requests by providing custom fetch functions and additional fetch options.
All generated RPC functions accept an optional fetchOptions
parameter that allows you to customize the underlying fetch request:
import { createTodo, listTodos } from './ash_rpc';
// Add request timeout and custom cache settings
const todo = await createTodo({
fields: ["id", "title"],
input: { title: "New Todo" },
fetchOptions: {
signal: AbortSignal.timeout(5000), // 5 second timeout
cache: 'no-cache',
credentials: 'include'
}
});
// Use with abort controller for cancellable requests
const controller = new AbortController();
const todos = await listTodos({
fields: ["id", "title"],
fetchOptions: {
signal: controller.signal
}
});
// Cancel the request if needed
controller.abort();
You can replace the native fetch function entirely by providing a customFetch
parameter. This is useful for:
- Adding global authentication
- Using alternative HTTP clients like axios
- Adding request/response interceptors
- Custom error handling
// Custom fetch with user preferences and tracking
const enhancedFetch = async (url: RequestInfo | URL, init?: RequestInit) => {
// Get user preferences from localStorage (safe, non-sensitive data)
const userLanguage = localStorage.getItem('userLanguage') || 'en';
const userTimezone = localStorage.getItem('userTimezone') || 'UTC';
const apiVersion = localStorage.getItem('preferredApiVersion') || 'v1';
// Generate correlation ID for request tracking
const correlationId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const customHeaders = {
'Accept-Language': userLanguage,
'X-User-Timezone': userTimezone,
'X-API-Version': apiVersion,
'X-Correlation-ID': correlationId,
};
return fetch(url, {
...init,
headers: {
...init?.headers,
...customHeaders
}
});
};
// Use custom fetch function
const todos = await listTodos({
fields: ["id", "title"],
customFetch: enhancedFetch
});
While AshTypescript uses the fetch API by default, you can create an adapter to use axios or other HTTP clients:
import axios from 'axios';
// Create axios adapter that matches fetch API
const axiosAdapter = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
try {
const url = typeof input === 'string' ? input : input.toString();
const axiosResponse = await axios({
url,
method: init?.method || 'GET',
headers: init?.headers,
data: init?.body,
timeout: 10000,
// Add other axios-specific options
validateStatus: () => true // Don't throw on HTTP errors
});
// Convert axios response to fetch Response
return new Response(JSON.stringify(axiosResponse.data), {
status: axiosResponse.status,
statusText: axiosResponse.statusText,
headers: new Headers(axiosResponse.headers as any)
});
} catch (error) {
if (error.response) {
// HTTP error status
return new Response(JSON.stringify(error.response.data), {
status: error.response.status,
statusText: error.response.statusText
});
}
throw error; // Network error
}
};
// Use axios for all requests
const todos = await listTodos({
fields: ["id", "title"],
customFetch: axiosAdapter
});
import { listTodos } from './ash_rpc';
// Complex filtering with pagination
const result = await listTodos({
fields: ["id", "title", "priority", "dueDate", { user: ["name"] }],
filter: {
and: [
{ status: { eq: "ongoing" } },
{ priority: { in: ["high", "urgent"] } },
{
or: [
{ dueDate: { lessThan: "2024-12-31" } },
{ user: { name: { eq: "John Doe" } } }
]
}
]
},
sort: "-priority,+dueDate",
page: {
limit: 20,
offset: 0,
count: true
}
});
if (result.success) {
console.log(`Found ${result.data.count} todos`);
console.log(`Showing ${result.data.results.length} results`);
console.log(`Has more: ${result.data.hasMore}`);
}
Full support for embedded resources with type safety:
# In your resource
attribute :metadata, MyApp.TodoMetadata do
public? true
end
// TypeScript usage
const todo = await getTodo({
fields: [
"id", "title",
{ metadata: ["priority", "tags", "customFields"] }
],
id: "todo-123"
});
Support for Ash union types with selective field access:
# In your resource
attribute :content, :union do
constraints types: [
text: [type: :string],
checklist: [type: MyApp.ChecklistContent]
]
end
// TypeScript usage with union field selection
const todo = await getTodo({
fields: [
"id", "title",
{ content: ["text", { checklist: ["items", "completedCount"] }] }
],
id: "todo-123"
});
Automatic tenant parameter handling for multitenant resources:
# Configuration
config :ash_typescript, require_tenant_parameters: true
// Tenant parameters automatically added to function signatures
const todos = await listTodos({
fields: ["id", "title"],
tenant: "org-123"
});
Full support for Ash calculations with type inference:
# In your resource
calculations do
calculate :full_name, :string do
expr(first_name <> " " <> last_name)
end
end
// TypeScript usage
const users = await listUsers({
fields: ["id", "firstName", "lastName", "fullName"]
});
AshTypescript generates Zod schemas for all your actions, enabling runtime type checking and form validation.
# config/config.exs
config :ash_typescript,
generate_zod_schemas: true,
zod_import_path: "zod", # or "@hookform/resolvers/zod" etc.
zod_schema_suffix: "ZodSchema"
For each action, AshTypescript generates validation schemas:
// Generated schema for creating a todo
export const createTodoZodSchema = z.object({
title: z.string().min(1),
description: z.string().optional(),
priority: z.enum(["low", "medium", "high", "urgent"]).optional(),
dueDate: z.date().optional(),
tags: z.array(z.string()).optional()
});
AshTypescript generates dedicated validation functions for client-side form validation:
import { validateCreateTodo } from './ash_rpc';
// Validate form input before submission
const validationResult = await validateCreateTodo({
input: {
title: "New Todo",
priority: "high"
}
});
if (!validationResult.success) {
// Handle validation errors
validationResult.errors.forEach(error => {
console.log(`Field ${error.fieldPath}: ${error.message}`);
});
}
AshTypescript automatically generates comprehensive filter types for all resources:
import { listTodos } from './ash_rpc';
// Complex filtering with full type safety
const todos = await listTodos({
fields: ["id", "title", "status", "priority"],
filter: {
and: [
{ status: { eq: "ongoing" } },
{ priority: { in: ["high", "urgent"] } },
{
or: [
{ dueDate: { lessThan: "2024-12-31" } },
{ isOverdue: { eq: true } }
]
}
]
},
sort: "-priority,+dueDate"
});
- Equality:
eq
,notEq
,in
- Comparison:
greaterThan
,greaterThanOrEqual
,lessThan
,lessThanOrEqual
- Logic:
and
,or
,not
- Relationships: Nested filtering on related resources
Define reusable, type-safe queries for server-side rendering and optimized data fetching:
defmodule MyApp.Domain do
use Ash.Domain, extensions: [AshTypescript.Rpc]
typescript_rpc do
resource MyApp.Todo do
# Regular RPC actions
rpc_action :list_todos, :read
# Typed query with predefined fields
typed_query :dashboard_todos, :read do
ts_result_type_name "DashboardTodosResult"
ts_fields_const_name "dashboardTodosFields"
fields [
:id, :title, :priority, :isOverdue,
%{
user: [:name, :email],
comments: [:id, :content]
}
]
end
end
end
end
// Generated type for the typed query result
export type DashboardTodosResult = Array<InferResult<TodoResourceSchema,
["id", "title", "priority", "isOverdue",
{
user: ["name", "email"],
comments: ["id", "content"]
}]
>>;
// Reusable field constant for client-side refetching
export const dashboardTodosFields = [
"id", "title", "priority", "isOverdue",
{
user: ["name", "email"],
comments: ["id", "content"]
}
] as const;
# In your Phoenix controller
defmodule MyAppWeb.DashboardController do
use MyAppWeb, :controller
def index(conn, _params) do
result = AshTypescript.Rpc.run_typed_query(:my_app, :dashboard_todos, %{}, conn)
case result do
%{"success" => true, "data" => todos} ->
render(conn, "index.html", todos: todos)
%{"success" => false, "errors" => errors} ->
conn
|> put_status(:bad_request)
|> render("error.html", errors: errors)
end
end
end
// Use the same field selection for client-side updates
const refreshedTodos = await listTodos({
fields: dashboardTodosFields,
filter: { isOverdue: { eq: true } }
});
Configure separate formatters for input parsing and output generation:
# config/config.exs
config :ash_typescript,
# How client field names are converted to internal Elixir fields (default is :camel_case)
input_field_formatter: :camel_case,
# How internal Elixir fields are formatted for client consumption (default is :camel_case)
output_field_formatter: :camel_case
:camel_case
-user_name
βuserName
:pascal_case
-user_name
βUserName
:snake_case
-user_name
βuser_name
- Custom formatter:
{MyModule, :format_field}
or{MyModule, :format_field, [extra_args]}
# Use different formatting for input vs output
config :ash_typescript,
input_field_formatter: :snake_case, # Client sends snake_case
output_field_formatter: :camel_case # Client receives camelCase
# 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",
# Field formatting
input_field_formatter: :camel_case,
output_field_formatter: :camel_case,
# Multitenancy
require_tenant_parameters: false,
# Zod schema generation
generate_zod_schemas: true,
zod_import_path: "zod",
zod_schema_suffix: "ZodSchema",
# Custom type imports
import_into_generated: [
%{
import_name: "CustomTypes",
file: "./customTypes"
}
]
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
Customize how field names are formatted in generated TypeScript:
# Default: snake_case β camelCase
# user_name β userName
# created_at β createdAt
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
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;
}
Preferred approach for generating TypeScript types along with other Ash extensions in your project.
# Generate types for all Ash extensions including AshTypescript
mix ash.codegen --dev
# With custom output location
mix ash.codegen --dev --output "assets/js/ash_rpc.ts"
When to use: When you have multiple Ash extensions (AshPostgres, etc.) and want to run codegen for all of them together. This is the recommended approach for most projects.
Generate TypeScript types, RPC clients, Zod schemas, and validation functions only for AshTypescript.
When to use: When you want to run codegen specifically for AshTypescript only in your project.
Options:
--output
- Output file path (default:assets/js/ash_rpc.ts
)--run_endpoint
- RPC run endpoint (default:/rpc/run
)--validate_endpoint
- RPC validate endpoint (default:/rpc/validate
)--check
- Check if generated code is up to date (useful for CI)--dry_run
- Print generated code without writing to file
Generated Content:
- TypeScript interfaces for all resources
- RPC client functions for each action
- Filter input types for type-safe querying
- Zod validation schemas (if enabled)
- Form validation functions
- Typed query constants and types
- Custom type imports
Examples:
# Basic generation (AshTypescript only)
mix ash_typescript.codegen
# Custom output location
mix ash_typescript.codegen --output "frontend/src/api/ash.ts"
# Custom RPC endpoints
mix ash_typescript.codegen \
--run_endpoint "/api/rpc/run" \
--validate_endpoint "/api/rpc/validate"
# Check if generated code is up to date (CI usage)
mix ash_typescript.codegen --check
# Preview generated code without writing to file
mix ash_typescript.codegen --dry_run
AshTypescript generates:
- TypeScript interfaces for all resources with metadata for field selection
- RPC client functions for each exposed action
- Validation functions for client-side form validation
- Filter input types for type-safe querying with comprehensive operators
- Zod schemas for runtime validation (when enabled)
- Typed query constants and result types for SSR
- Field selection types for type-safe field specification
- Custom type imports for external TypeScript definitions
- Enum types for Ash enum types
- Utility functions for headers and CSRF protection
For each rpc_action
in your domain, AshTypescript generates:
// For rpc_action :list_todos, :read
function listTodos<Fields extends ListTodosFields>(params: {
fields: Fields;
filter?: TodoFilterInput;
sort?: string;
page?: PaginationOptions;
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}): Promise<ListTodosResult<Fields>>;
// Validation function for list_todos
function validateListTodos(params: {
input: ListTodosInput;
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}): Promise<ValidateListTodosResult>;
// For rpc_action :create_todo, :create
function createTodo<Fields extends CreateTodosFields>(params: {
fields: Fields;
input: CreateTodoInput;
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}): Promise<CreateTodoResult<Fields>>;
// Validation function for create_todo
function validateCreateTodo(params: {
input: CreateTodoInput;
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}): Promise<ValidateCreateTodoResult>;
// Zod schemas (when enabled)
export const createTodoZodSchema: z.ZodObject<...>;
export const listTodosZodSchema: z.ZodObject<...>;
// CSRF protection for Phoenix applications
function getPhoenixCSRFToken(): string | null;
function buildCSRFHeaders(): Record<string, string>;
- Elixir ~> 1.15
- Ash ~> 3.5
- AshPhoenix ~> 2.0 (for RPC endpoints)
TypeScript compilation errors:
- Ensure generated types are up to date:
mix ash_typescript.codegen
- Check that all referenced resources are properly configured
RPC endpoint errors:
- Verify AshPhoenix RPC endpoints are configured in your router
- Check that actions are properly exposed in domain RPC configuration
Type inference issues:
- Ensure all attributes are marked as
public? true
- Check that relationships are properly defined
# Check generated output without writing
mix ash_typescript.codegen --dry_run
# Validate TypeScript compilation
cd assets/js && npx tsc --noEmit
# Check for updates
mix ash_typescript.codegen --check
# Clone the repository
git clone https://github.com/ash-project/ash_typescript.git
cd ash_typescript
# Install dependencies
mix deps.get
# Run tests
mix test
# Generate test types
mix test.codegen
This project is licensed under the MIT License - see the LICENSE file for details.
- Documentation: hexdocs.pm/ash_typescript
- Issues: GitHub Issues
- Discussions: GitHub Discussions
- Ash Community: Ash Framework Discord
Built with β€οΈ by the Ash Framework team
Generate once, type everywhere. Make your Elixir-TypeScript integration bulletproof.