Typed Queries

View Source

Typed queries provide type-safe access to server-fetched data in full-stack Phoenix applications. When your Phoenix controller fetches data and passes it to the frontend as page props, typed queries ensure proper TypeScript types for that data.

When to Use Typed Queries

Typed queries are designed for full-stack web applications where your Phoenix backend serves your frontend directly. We recommend using Inertia.js with React or Svelte for this architecture—it provides seamless SSR, type-safe page props, and excellent developer experience.

The Problem: Passing Ash Data to Inertia

When using Inertia.js, Phoenix controllers pass data as props to your React/Svelte pages. Without typed queries, you face two problems:

Problem 1: JSON Encoding Errors

Ash resource structs contain internal metadata that cannot be serialized:

# This will FAIL with Jason encoding errors
def index(conn, _params) do
  todos = Ash.read!(MyApp.Todo)

  conn
  |> assign_prop(:todos, todos)  # 💥 Protocol.UndefinedError!
  |> render_inertia("TodoList")
end

The error: protocol Jason.Encoder not implemented for MyApp.Todo (a struct)

Problem 2: No Type Safety

Even if you manually convert to maps, your frontend has no type information:

<script lang="ts">
  interface Props {
    todos: any[];  // 😢 No type safety
  }

  let { todos }: Props = $props();
</script>

How Typed Queries Solve This

Typed queries define the field selection once in Elixir, then generate:

  1. Plain maps - Safe for JSON serialization
  2. A TypeScript result type - The exact shape of data returned
  3. A fields constant - For client-side re-fetching if needed
# Define once in your domain
typed_query :dashboard_todo, :read do
  ts_result_type_name "DashboardTodo"
  ts_fields_const_name "dashboardTodoFields"

  fields [:id, :title, :priority, %{user: [:name]}]
end
// Generated TypeScript
export type DashboardTodo = {
  id: string;
  title: string;
  priority: "low" | "medium" | "high";
  user: { name: string };
};

export const dashboardTodoFields = [
  "id", "title", "priority", { user: ["name"] }
] as const;

Complete Inertia Example

Step 1: Define the Typed Query

defmodule MyApp.Domain do
  use Ash.Domain, extensions: [AshTypescript.Rpc]

  typescript_rpc do
    resource MyApp.Todo do
      rpc_action :list_todos, :read

      # Typed query for dashboard view
      typed_query :dashboard_todo, :read do
        ts_result_type_name "DashboardTodo"
        ts_fields_const_name "dashboardTodoFields"

        fields [
          :id,
          :title,
          :priority,
          :status,
          :completed,
          %{
            user: [:name, :avatar_url],
            tags: [:name, :color]
          }
        ]
      end

      # Typed query for list view (minimal fields for performance)
      typed_query :todo_list_item, :read do
        ts_result_type_name "TodoListItem"
        ts_fields_const_name "todoListItemFields"

        fields [:id, :title, :completed, :priority]
      end
    end
  end
end

Step 2: Use in Your Phoenix Controller

defmodule MyAppWeb.TodoController do
  use MyAppWeb, :controller

  def index(conn, _params) do
    # Use typed query - returns plain maps safe for JSON
    todos =
      case AshTypescript.Rpc.run_typed_query(
             :my_app,              # Domain name (atom)
             :dashboard_todo,      # Typed query name
             %{},                  # Arguments (if any)
             conn                  # Connection (for actor/authorization)
           ) do
        %{"success" => true, "data" => data} -> data
        _ -> []
      end

    conn
    |> assign_prop(:todos, todos)  # ✅ Safe - plain maps
    |> render_inertia("TodoList")
  end
end

Important pattern matching:

  • Response is a map with string keys, not a tuple
  • Pattern match on %{"success" => true, "data" => data}
  • NOT {:ok, data} (common mistake!)

Step 3: Use Generated Types in Your Page

Svelte example:

<script lang="ts">
  import type { DashboardTodo } from '$js/ash_rpc';

  interface Props {
    todos: DashboardTodo[];  // ✅ Type-safe!
  }

  let { todos }: Props = $props();
</script>

<ul>
  {#each todos as todo (todo.id)}
    <li>
      {todo.title}              <!-- ✅ Autocomplete works -->
      {todo.user.name}          <!-- ✅ Type-safe nested access -->
      {todo.priority}           <!-- ✅ TypeScript knows it's "low" | "medium" | "high" -->
    </li>
  {/each}
</ul>

React example:

import type { DashboardTodo } from '../ash_rpc';

interface Props {
  todos: DashboardTodo[];
}

export default function TodoList({ todos }: Props) {
  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>
          {todo.title}           {/* ✅ Autocomplete works */}
          {todo.user.name}       {/* ✅ Type-safe nested access */}
          {todo.priority}        {/* ✅ TypeScript knows it's "low" | "medium" | "high" */}
        </li>
      ))}
    </ul>
  );
}

Client-Side Re-fetching

The generated fields constant allows client-side re-fetching with the same shape:

<script lang="ts">
  import { listTodos, dashboardTodoFields, type DashboardTodo } from '$js/ash_rpc';

  interface Props {
    todos: DashboardTodo[];
  }

  let { todos: initialTodos }: Props = $props();
  let todos = $state(initialTodos);
  let loading = $state(false);

  async function refresh() {
    loading = true;
    const result = await listTodos({ fields: dashboardTodoFields });
    if (result.success) {
      todos = result.data;  // Same type as initial props
    }
    loading = false;
  }
</script>

<button onclick={refresh} disabled={loading}>
  {loading ? 'Refreshing...' : 'Refresh'}
</button>

<ul>
  {#each todos as todo (todo.id)}
    <li>{todo.title}</li>
  {/each}
</ul>

Passing Arguments

Use atom keys for arguments (you're in Elixir), wrapped in :input:

def show(conn, %{"id" => id}) do
  todo =
    case AshTypescript.Rpc.run_typed_query(
           :my_app,
           :todo_detail,
           %{input: %{id: id}},  # ✅ Atom keys, wrapped in :input
           conn
         ) do
      %{"success" => true, "data" => data} -> data
      _ -> nil
    end

  if todo do
    conn
    |> assign_prop(:todo, todo)
    |> render_inertia("TodoDetail")
  else
    conn
    |> put_flash(:error, "Todo not found")
    |> redirect(to: "/todos")
  end
end

Pagination

Use maps (not keyword lists) for pagination:

def index(conn, params) do
  page_opts = %{limit: 50}
  page_opts = if params["after"], do: Map.put(page_opts, :after, params["after"]), else: page_opts

  todos =
    case AshTypescript.Rpc.run_typed_query(
           :my_app,
           :todo_list,
           %{page: page_opts},
           conn
         ) do
      %{"success" => true, "data" => data} -> data
      _ -> []
    end

  # ...
end

Configuration Options

OptionRequiredDescription
ts_result_type_nameYesName for the generated TypeScript result type
ts_fields_const_nameYesName for the generated fields constant
fieldsYesPre-configured field selection array

Best Practices

1. Name Types by View/Purpose

Name your typed queries after where they're used:

# Good - describes the view/purpose
typed_query :dashboard_todo, :read do ...
typed_query :todo_list_item, :read do ...
typed_query :admin_todo_detail, :read do ...

# Avoid - describes content
typed_query :todo_with_user, :read do ...
typed_query :todo_full, :read do ...

2. Minimize Queries Per Page

When possible, design typed queries to fetch all needed data in a single call rather than making multiple queries:

# Good - single query with nested data
typed_query :todo_detail, :read do
  fields [
    :id, :title, :description, :completed,
    %{user: [:name, :email], comments: [:id, :text, %{author: [:name]}]}
  ]
end

# Avoid - multiple separate queries for the same page
# typed_query :todo_basic, :read do ...
# typed_query :todo_user, :read do ...
# typed_query :todo_comments, :read do ...

3. Never Create Custom Interfaces

Always use the generated types—never duplicate:

<!-- ❌ WRONG - Custom interface that can drift -->
<script lang="ts">
  interface Todo {
    id: string;
    title: string;
    user: { name: string };
  }

  interface Props {
    todos: Todo[];
  }
</script>

<!-- ✅ CORRECT - Use generated type -->
<script lang="ts">
  import type { DashboardTodo } from '$js/ash_rpc';

  interface Props {
    todos: DashboardTodo[];
  }
</script>

4. Match Server and Client Queries

If you support client-side re-fetching, use the same fields constant:

<script lang="ts">
  import { listTodos, dashboardTodoFields, type DashboardTodo } from '$js/ash_rpc';

  // Initial data from server (uses same typed query)
  interface Props {
    todos: DashboardTodo[];
  }

  let { todos: initialTodos }: Props = $props();
  let todos = $state(initialTodos);

  // Re-fetch uses same fields
  async function refresh() {
    const result = await listTodos({ fields: dashboardTodoFields });
    if (result.success) {
      todos = result.data;  // Guaranteed same shape
    }
  }
</script>

Common Mistakes

❌ Using Ash.read Directly

# WRONG - Will cause Jason encoding errors
def index(conn, _params) do
  todos = Ash.read!(MyApp.Todo)

  conn
  |> assign_prop(:todos, todos)  # 💥 ERROR!
  |> render_inertia("TodoList")
end

❌ Wrong Pattern Matching

# WRONG - Response is a map, not a tuple
case AshTypescript.Rpc.run_typed_query(:my_app, :todos, %{}, conn) do
  {:ok, data} -> data        # Will never match!
  {:error, _} -> []
end

# CORRECT
case AshTypescript.Rpc.run_typed_query(:my_app, :todos, %{}, conn) do
  %{"success" => true, "data" => data} -> data
  _ -> []
end

❌ String Keys for Arguments

# WRONG - String keys for input
run_typed_query(:my_app, :todo_detail, %{"id" => id}, conn)

# CORRECT - Atom keys wrapped in :input
run_typed_query(:my_app, :todo_detail, %{input: %{id: id}}, conn)

❌ Keyword Lists for Pagination

# WRONG - Keyword list
page_opts = [limit: 50, after: cursor]

# CORRECT - Map
page_opts = %{limit: 50, after: cursor}

Next Steps