documentation/features/typed-channels.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 Channel Event Subscriptions

AshTypescript can generate typed TypeScript event subscriptions from Ash PubSub publications. This enables type-safe handling of server-pushed events over Phoenix channels.

## When to Use Typed Channels

Use `AshTypescript.TypedChannel` when your application pushes events to clients via Ash PubSub and you want typed payloads on the frontend.

| Use Case | Recommended Approach |
|----------|---------------------|
| Server pushes events to clients (notifications, updates) | **TypedChannel** |
| Client sends requests, server responds (CRUD, queries) | [RPC Actions](../guides/crud-operations.md) |
| Client sends requests over WebSocket | [Channel-based RPC](phoenix-channels.md) |
| Controller-style routes (Inertia, redirects) | [Typed Controllers](../guides/typed-controllers.md) |

## Requirements

Typed channels require **Ash >= 3.21.1**, which introduced `returns`, `public?`, and calculation `transform` support on PubSub publications, as well as `:auto`-typed calculations as transforms.

## Quick Start

### 1. Add PubSub publications with calculation transforms

The recommended way to get typed payloads is to use `transform :some_calc` on
publications, pointing to a resource calculation with `:auto` typing. Ash
automatically derives the `returns` type from the calculation expression, so
AshTypescript gets the type information it needs without manual `returns`
declarations.

```elixir
defmodule MyApp.Post do
  use Ash.Resource,
    domain: MyApp.Domain,
    notifiers: [Ash.Notifier.PubSub]

  pub_sub do
    module MyAppWeb.Endpoint
    prefix "posts"

    publish :create, [:id],
      event: "post_created",
      public?: true,
      transform: :post_summary

    publish :update, [:id],
      event: "post_updated",
      public?: true,
      transform: :post_title
  end

  calculations do
    calculate :post_summary, :auto, expr(%{id: id, title: title}) do
      public? true
    end

    calculate :post_title, :auto, expr(title) do
      public? true
    end
  end

  # ... attributes, actions, etc.
end
```

You can also use explicit `returns` with an anonymous function transform, but
this requires manually keeping the type and transform in sync:

```elixir
publish :create, [:id],
  event: "post_created",
  public?: true,
  returns: :map,
  constraints: [
    fields: [
      id: [type: :uuid, allow_nil?: false],
      title: [type: :string, allow_nil?: true]
    ]
  ],
  transform: fn notification ->
    %{id: notification.data.id, title: notification.data.title}
  end
```

### 2. Define your channel

A typed channel consists of two parts: a DSL module that declares which events get TypeScript types, and a Phoenix channel that handles runtime behavior. You can put them in the same module or keep them separate.

```elixir
defmodule MyAppWeb.OrgChannel do
  # DSL for TypeScript codegen — declares which events to type
  use AshTypescript.TypedChannel

  # Phoenix channel for runtime behavior
  use Phoenix.Channel

  typed_channel do
    topic "org:*"

    resource MyApp.Post do
      publish :post_created
      publish :post_updated
    end

    resource MyApp.Comment do
      publish :comment_created
    end
  end

  # Authorization — you own this logic
  @impl true
  def join("org:" <> org_id, _payload, socket) do
    if authorized?(socket, org_id) do
      {:ok, socket}
    else
      {:error, %{reason: "unauthorized"}}
    end
  end

  # Handle incoming messages from the client (if needed)
  @impl true
  def handle_in("ping", _payload, socket) do
    {:reply, {:ok, %{message: "pong"}}, socket}
  end
end
```

Register the channel in your socket:

```elixir
defmodule MyAppWeb.UserSocket do
  use Phoenix.Socket

  channel "org:*", MyAppWeb.OrgChannel

  @impl true
  def connect(params, socket, _connect_info) do
    {:ok, socket}
  end

  @impl true
  def id(_socket), do: nil
end
```

A single channel can subscribe to events from any number of resources. All events are merged into one typed events map. Event names must be unique across all resources in a channel.

### 3. Configure AshTypescript

```elixir
# config/config.exs
config :ash_typescript,
  typed_channels: [MyAppWeb.OrgChannel],
  typed_channels_output_file: "assets/js/ash_typed_channels.ts"
```

### 4. Generate TypeScript

```bash
mix ash_typescript.codegen
```

### 5. Use in your frontend

```typescript
import { createOrgChannel, onOrgChannelMessages, unsubscribeOrgChannel } from './ash_typed_channels';

// Create a branded channel instance
const channel = createOrgChannel(socket, orgId);
channel.join();

// Subscribe to events with full type safety
const refs = onOrgChannelMessages(channel, {
  post_created: (payload) => {
    // payload is typed as { id: UUID, title: string | null }
    console.log("New post:", payload.title);
  },
  post_updated: (payload) => {
    // payload is typed as string
    console.log("Updated title:", payload);
  },
});

// Cleanup when done
unsubscribeOrgChannel(channel, refs);
```

## DSL Reference

### `typed_channel` Section

| Option | Type | Required | Description |
|--------|------|----------|-------------|
| `topic` | string | Yes | Phoenix channel topic pattern (e.g. `"org:*"`) |

### `resource` Entity

Declares an Ash resource whose PubSub publications this channel subscribes to.

```elixir
resource MyApp.Post do
  publish :post_created
  publish :post_updated
end
```

| Option | Type | Required | Description |
|--------|------|----------|-------------|
| `module` | atom | Yes | Ash resource module (positional argument) |

### `publish` Entity

Declares a specific PubSub event to subscribe to.

| Option | Type | Required | Description |
|--------|------|----------|-------------|
| `event` | atom/string | Yes | Event name matching a publication on the resource (positional argument) |

The event name must match the `event:` option (or action name fallback) of a publication in the resource's `pub_sub` block.

## Generated TypeScript

### Types (in `ash_types.ts`)

For each configured channel, the following types are generated:

```typescript
// Branded channel type - prevents mixing channel instances
export type OrgChannel = {
  readonly __channelType: "OrgChannel";
  on(event: string, callback: (payload: unknown) => void): number;
  off(event: string, ref: number): void;
};

// Payload type aliases (one per event)
export type PostCreatedPayload = {id: UUID, title: string | null};
export type PostUpdatedPayload = string;
export type CommentCreatedPayload = unknown;

// Events map - maps event names to payload types
export type OrgChannelEvents = {
  post_created: PostCreatedPayload;
  post_updated: PostUpdatedPayload;
  comment_created: CommentCreatedPayload;
};

// Utility types for multi-subscribe and cleanup
export type OrgChannelHandlers = {
  [E in keyof OrgChannelEvents]?: (payload: OrgChannelEvents[E]) => void;
};
export type OrgChannelRefs = {
  [E in keyof OrgChannelEvents]?: number;
};
```

### Functions (in typed channels output file)

```typescript
// Factory - creates a branded channel instance
export function createOrgChannel(
  socket: { channel(topic: string, params?: object): unknown },
  suffix: string
): OrgChannel {
  return socket.channel(`org:${suffix}`) as OrgChannel;
}

// Single-event subscription (generic over event name)
export function onOrgChannelMessage<E extends keyof OrgChannelEvents>(
  channel: OrgChannel,
  event: E,
  handler: (payload: OrgChannelEvents[E]) => void
): number { ... }

// Multi-event subscription (subscribe to multiple events at once)
export function onOrgChannelMessages(
  channel: OrgChannel,
  handlers: OrgChannelHandlers
): OrgChannelRefs { ... }

// Cleanup (unsubscribe all refs)
export function unsubscribeOrgChannel(
  channel: OrgChannel,
  refs: OrgChannelRefs
): void { ... }
```

## Topic Patterns

The topic string determines the factory function signature:

| Topic Pattern | Factory Signature | Usage |
|--------------|-------------------|-------|
| `"org:*"` (wildcard) | `createOrgChannel(socket, suffix)` | `createOrgChannel(socket, orgId)` |
| `"global"` (no wildcard) | `createGlobalChannel(socket)` | `createGlobalChannel(socket)` |

Wildcard topics require a `suffix` parameter that replaces the `*`. The factory constructs the full topic string (e.g., `org:${suffix}`).

## Payload Type Resolution

The TypeScript payload type is derived from the publication's `returns` type. When using `transform :some_calc`, Ash auto-populates `returns` from the calculation's type. You can also set `returns` explicitly.

| `returns` Value | TypeScript Type | How to Get It |
|----------------|-----------------|---------------|
| `:string` | `string` | `calculate :my_calc, :auto, expr(name)` or explicit `returns: :string` |
| `:integer` | `number` | `calculate :my_calc, :auto, expr(priority)` or explicit `returns: :integer` |
| `:boolean` | `boolean` | `calculate :my_calc, :auto, expr(active == true)` or explicit `returns: :boolean` |
| `:uuid` | `UUID` | `calculate :my_calc, :auto, expr(id)` or explicit `returns: :uuid` |
| `:utc_datetime` | `UtcDateTime` | Explicit `returns: :utc_datetime` |
| `:map` with `fields` | `{fieldName: type, ...}` | `calculate :my_calc, :auto, expr(%{id: id, name: name})` or explicit `returns: :map` with `constraints` |
| Not set | `unknown` | Missing `transform :calc` and no explicit `returns` |

Map types with `:fields` constraints generate plain object types without the `__type`/`__primitiveFields` metadata used by the RPC field-selection system.

### Multi-Channel Payload Deduplication

When multiple channels are configured, payload type aliases are deduplicated by name. If two channels both subscribe to `article_published` from the same resource, only one `ArticlePublishedPayload` type is emitted in `ash_types.ts`.

If two different resources declare publications with the same event name but different `returns` types (whether auto-derived or explicit) and those resources appear in separate channels, codegen will raise an error:

```
Payload type name conflict detected across typed channels.
```

To fix this, rename the conflicting events to be unique, or ensure they return the same type.

## Frontend Usage Patterns

### Single-Event Subscription

```typescript
const ref = onOrgChannelMessage(channel, "post_created", (payload) => {
  // payload is PostCreatedPayload
  addPostToList(payload);
});

// Unsubscribe later
channel.off("post_created", ref);
```

### Multi-Event Subscription

```typescript
const refs = onOrgChannelMessages(channel, {
  post_created: (payload) => addPostToList(payload),
  post_updated: (payload) => updatePostTitle(payload),
  comment_created: (payload) => showNotification(payload),
});

// Unsubscribe all at once
unsubscribeOrgChannel(channel, refs);
```

### With Svelte or React

```typescript
// Svelte example
onMount(() => {
  const channel = createOrgChannel(socket, orgId);
  channel.join();

  const refs = onOrgChannelMessages(channel, {
    post_created: (payload) => posts = [...posts, payload],
  });

  return () => {
    unsubscribeOrgChannel(channel, refs);
    channel.leave();
  };
});
```

## Compile-Time Verification

The DSL verifier checks your configuration at compile time:

| Check | Severity | What It Catches |
|-------|----------|----------------|
| Event exists | Error | Declared event doesn't match any publication on the resource |
| Unique events | Error | Same event name used across multiple resources in one channel |
| `public?: true` | Warning | Publication not marked as public |
| `returns` set | Warning | Publication missing `returns` — no `transform :calc` or explicit `returns:` (payload type becomes `unknown`) |

## Configuration

```elixir
config :ash_typescript,
  typed_channels: [MyApp.OrgChannel, MyApp.ActivityChannel],
  typed_channels_output_file: "assets/js/ash_typed_channels.ts"
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `typed_channels` | `list(module)` | `[]` | Modules using `AshTypescript.TypedChannel` |
| `typed_channels_output_file` | `string \| nil` | `nil` | Output file for channel functions (when `nil`, no file is generated) |

Channel types are appended to `ash_types.ts`. Channel functions go into the separate `typed_channels_output_file` and import their types from `ash_types.ts`.

## Next Steps

- [Phoenix Channel-based RPC](phoenix-channels.md) - Request/response RPC over channels
- [Configuration Reference](../reference/configuration.md) - All configuration options
- [Lifecycle Hooks](lifecycle-hooks.md) - Add hooks for logging and telemetry