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 |
| Client sends requests over WebSocket | Channel-based RPC |
| Controller-style routes (Inertia, redirects) | Typed Controllers |
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.
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.
endYou can also use explicit returns with an anonymous function transform, but
this requires manually keeping the type and transform in sync:
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}
end2. Define your channel
A typed channel is a Phoenix channel that also uses the AshTypescript.TypedChannel DSL to declare which events get TypeScript types. Both use statements go in the same module — a compile warning is emitted if use Phoenix.Channel is missing, because the typed channel machinery hooks into the channel itself (see Payload formatting below).
defmodule MyAppWeb.OrgChannel do
# Phoenix channel for runtime behavior
use Phoenix.Channel
# DSL for TypeScript codegen — declares which events to type
use AshTypescript.TypedChannel
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 (define authorized?/2 yourself)
@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
endRegister the channel in your socket:
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
endA 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
# config/config.exs
config :ash_typescript,
typed_channels: [MyAppWeb.OrgChannel],
typed_channels_output_file: "assets/js/ash_typed_channels.ts"4. Generate TypeScript
mix ash_typescript.codegen
5. Use in your frontend
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.
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:
// 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;
join(timeout?: number): unknown;
leave(timeout?: number): unknown;
};
// 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)
// 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.
Payload Formatting
The generated payload types use the configured output_field_formatter (e.g. conversation_id becomes conversationId with :camel_case), matching the rest of the generated TypeScript. To make the wire payload match, use AshTypescript.TypedChannel automatically intercepts every declared event (via Phoenix.Channel.intercept/1) and injects handle_out/3 clauses that format the broadcast payload — using the publication's returns type and the same type-driven formatter as RPC results — before pushing to the client.
Things to know:
- Formatting happens at the websocket boundary only. Elixir-side subscribers of the same PubSub topics (e.g. LiveViews) still receive the raw, untransformed payload.
- Your own
interceptcalls andhandle_out/3clauses are preserved — the typed events are merged into the intercept list, and no clause is injected for an event you already handle yourself, so your clause owns the push (and you are responsible for formatting that payload). - If you do write your own
handle_out/3for a declared event, add it to your ownintercept/1list as well. Phoenix checks the intercept list at definition time, before AshTypescript merges the typed events in, so it otherwise prints a spurious "An intercept for event ... has not yet been defined" warning. Listing it twice is harmless — the lists are deduplicated. - Intercepting disables Phoenix's fastlane optimization for the declared events: payloads are encoded once per subscriber instead of once per broadcast. This is the standard cost of
Phoenix.Channel.intercept/1and is usually negligible. - Because the interception must live in the channel that serves the topic, the DSL module must itself be a Phoenix channel — a compile warning is emitted otherwise, and payloads would reach clients unformatted.
Frontend Usage Patterns
Single-Event Subscription
const ref = onOrgChannelMessage(channel, "post_created", (payload) => {
// payload is PostCreatedPayload
addPostToList(payload);
});
// Unsubscribe later
channel.off("post_created", ref);Multi-Event Subscription
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
// 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
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 - Request/response RPC over channels
- Configuration Reference - All configuration options
- Lifecycle Hooks - Add hooks for logging and telemetry