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 CaseRecommended Approach
Server pushes events to clients (notifications, updates)TypedChannel
Client sends requests, server responds (CRUD, queries)RPC Actions
Client sends requests over WebSocketChannel-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.
end

You 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}
  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.

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:

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

# 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

OptionTypeRequiredDescription
topicstringYesPhoenix 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
OptionTypeRequiredDescription
moduleatomYesAsh resource module (positional argument)

publish Entity

Declares a specific PubSub event to subscribe to.

OptionTypeRequiredDescription
eventatom/stringYesEvent 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;
};

// 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 PatternFactory SignatureUsage
"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 ValueTypeScript TypeHow to Get It
:stringstringcalculate :my_calc, :auto, expr(name) or explicit returns: :string
:integernumbercalculate :my_calc, :auto, expr(priority) or explicit returns: :integer
:booleanbooleancalculate :my_calc, :auto, expr(active == true) or explicit returns: :boolean
:uuidUUIDcalculate :my_calc, :auto, expr(id) or explicit returns: :uuid
:utc_datetimeUtcDateTimeExplicit returns: :utc_datetime
:map with fields{fieldName: type, ...}calculate :my_calc, :auto, expr(%{id: id, name: name}) or explicit returns: :map with constraints
Not setunknownMissing 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

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:

CheckSeverityWhat It Catches
Event existsErrorDeclared event doesn't match any publication on the resource
Unique eventsErrorSame event name used across multiple resources in one channel
public?: trueWarningPublication not marked as public
returns setWarningPublication 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"
OptionTypeDefaultDescription
typed_channelslist(module)[]Modules using AshTypescript.TypedChannel
typed_channels_output_filestring | nilnilOutput 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