documentation/guides/typed-queries.md

<!--
SPDX-FileCopyrightText: 2025 Torkild G. Kjevik
SPDX-FileCopyrightText: 2025 ash_typescript contributors <https://github.com/ash-project/ash_typescript/graphs/contributors>

SPDX-License-Identifier: MIT
-->

# Typed Queries

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](https://inertiajs.com/) 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:

```elixir
# 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:

```svelte
<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

```elixir
# 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
```

```typescript
// 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

```elixir
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

```elixir
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:**

```svelte
<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:**

```tsx
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:

```svelte
<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`:

```elixir
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:

```elixir
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

| Option | Required | Description |
|--------|----------|-------------|
| `ts_result_type_name` | Yes | Name for the generated TypeScript result type |
| `ts_fields_const_name` | Yes | Name for the generated fields constant |
| `fields` | Yes | Pre-configured field selection array |

## Best Practices

### 1. Name Types by View/Purpose

Name your typed queries after where they're used:

```elixir
# 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:

```elixir
# 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:

```svelte
<!-- ❌ 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:

```svelte
<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

```elixir
# 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

```elixir
# 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

```elixir
# 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

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

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

## Next Steps

- [Field Selection](field-selection.md) - Dynamic field selection for RPC actions
- [Querying Data](querying-data.md) - Filtering, sorting, pagination
- [RPC Action Options](../features/rpc-action-options.md) - Load restrictions for security
- [Frontend Frameworks](../getting-started/frontend-frameworks.md) - Framework-specific setup