Typed RPC procedures for Elixir servers with TypeScript-compatible type resolution from @spec.

Part of elixir-ts-rpc — a typed RPC layer between Elixir servers and TypeScript clients.

📖 Full guide & documentation → ostatni5.github.io/elixir-ts-rpc

Status: early release (0.0.1), pre-1.0 — APIs may change between minor versions. The full HTTP/Plug RPC stack is implemented and tested — Context, Resolution, Types, CustomType, Types.FromSpec, plus Handler, Router, Middleware, Dispatcher, and Plug — along with TypeScript codegen (mix rpc.gen.ts). Realtime transports (SSE, Phoenix Channels) are not built yet. See the CHANGELOG for what's in each release.

Requirements: Elixir ~> 1.19 (OTP 26+).

Getting started in your own app

End-to-end: from an empty handler to a typed call in the browser. Assumes a Mix project with Plug already in your supervision tree (e.g. via plug_cowboy or Phoenix's endpoint).

1. Add the dependency

Add it as a Hex dep:

# mix.exs
def deps do
  [
    {:elixir_ts_rpc, "~> 0.0.1"}
  ]
end

For monorepo/local work, use a path or GitHub dep instead — see Installation below.

2. Write a handler with a @spec

A handler is a plain module whose functions take (input, ctx) and return {:ok, output} | {:error, error}. Write a classic @spec — that is the only type source. use RpcElixir.Handler is the recommended default: it lets the handler and router live in the same Mix project (see Handler compilation).

input always arrives with atom keys — pattern-match on %{id: id}, never %{"id" => id}.

defmodule MyApp.Handlers.Users do
  use RpcElixir.Handler

  @spec get(%{id: integer()}, RpcElixir.Context.t()) ::
          {:ok, %{id: integer(), name: String.t()}} | {:error, :not_found}
  def get(%{id: id}, _ctx) do
    case MyApp.Users.fetch(id) do
      {:ok, user} -> {:ok, %{id: user.id, name: user.name}}
      :error -> {:error, :not_found}
    end
  end
end

3. Register it in a router

defmodule MyApp.RpcRouter do
  use RpcElixir.Router

  procedure "users.get", &MyApp.Handlers.Users.get/2
end

Each procedure takes a wire name and a remote function capture of arity 2. The router validates the @spec at compile time. The DSL also has scope (shares a name prefix and/or middleware across a group) and expose (registers every @spec'd arity-2 function of a handler module) — see RpcElixir.Router.

The DSL reads best without parens (procedure "users.get", &…). So that mix format keeps it that way instead of rewriting to procedure(…), import this library's formatter config in your .formatter.exs:

# .formatter.exs
[
  import_deps: [:elixir_ts_rpc]
]

(mix format won't strip parens that are already there, so format once after adding this.)

4. Mount the plug in your endpoint

defmodule MyApp.Endpoint do
  use Plug.Builder

  plug RpcElixir.Plug, router: MyApp.RpcRouter
end

A request to POST /rpc/users.get now dispatches the "users.get" procedure. (:path_prefix defaults to "/rpc".)

5. Configure codegen

Point the codegen at your router and an output path, then add the compiler so the client regenerates on every mix compile:

# config/config.exs
config :elixir_ts_rpc,
  router: MyApp.RpcRouter,
  out: Path.expand("../assets/src/rpc.gen.ts", __DIR__)
# mix.exs
def project do
  [
    # ...
    compilers: Mix.compilers() ++ [:elixir_ts_rpc]
  ]
end

See Choosing a codegen workflow for when to use the compiler hook vs. the mix rpc.gen.ts.watch task vs. the one-off mix rpc.gen.ts task instead.

6. Make a typed call from TypeScript

Install the runtime client (npm install @elixir-ts-rpc/client) and import the generated factory:

import { createRpcClient } from "./rpc.gen";

const client = createRpcClient({ baseUrl: "/rpc" });

// Fully typed: input { id: number }, output { id: number; name: string },
// and a catchable RpcError<"not_found"> on the error path.
const user = await client.users.get({ id: 1 });

See @elixir-ts-rpc/client for catching typed errors, abort signals, and cross-origin auth.

Installation

The package is on Hex — see Add the dependency above. For monorepo/local development, use a path or GitHub dep instead:

# same umbrella / monorepo
def deps do
  [
    {:elixir_ts_rpc, path: "../rpc_elixir"}
  ]
end
# from GitHub
def deps do
  [
    {:elixir_ts_rpc, github: "ostatni5/elixir-ts-rpc", sparse: "apps/rpc_elixir"}
  ]
end

Name notes: the Hex package / OTP application name is :elixir_ts_rpc (use it in deps, in config :elixir_ts_rpc, ..., and in compilers:). The Elixir module namespace is RpcElixir.* (RpcElixir.Router, RpcElixir.Plug, use RpcElixir.Handler).

Quick example

Define a handler module with a @spec following the RPC convention (call(input, context) :: {:ok, output} | {:error, error}). use RpcElixir.Handler is the recommended default — it captures the @spec AST so the handler and router can live in the same Mix project without parallel-compiler races (see Handler compilation):

defmodule MyApp.Handlers.Users do
  use RpcElixir.Handler

  @type get_user_input :: %{id: integer()}
  @type user :: %{id: integer(), name: String.t()}

  @spec get_user(get_user_input(), RpcElixir.Context.t()) ::
          {:ok, user()} | {:error, :not_found}
  def get_user(%{id: id}, _ctx) do
    # ...
  end
end

The types are resolved from the compiled module's debug info — no compile-time macro is required, and the router does this for you. To inspect the resolution directly:

alias RpcElixir.Types.FromSpec

{:ok, %{input: input, output: output, error: error}} =
  FromSpec.fetch_rpc(MyApp.Handlers.Users, :get_user)

# input  => %{kind: "object", fields: %{id: %{kind: "primitive", type: "integer"}}}
# output => %{kind: "object", fields: %{id: %{kind: "primitive", type: "integer"}, name: %{kind: "primitive", type: "string"}}}
# error  => %{kind: "primitive", type: "atom"}

Handler compilation

RpcElixir.Router validates handler @specs inside its __before_compile__ hook. It can read those specs two ways:

  • use RpcElixir.Handler (recommended default). The macro captures the @spec AST into a __rpc_specs__/0 accessor, and the router calls that accessor. The function-call edge forces the parallel compiler to finish the handler before the router's hook runs — so handlers and router can live in the same Mix project.
  • BEAM on disk (advanced). Without use RpcElixir.Handler, the router reads specs from the handler's compiled BEAM via Code.Typespec.fetch_specs/1. In a single Mix project the parallel compiler may run the router's hook before in-progress handler BEAMs are flushed, producing spurious "no @spec" errors. To use this path reliably the handlers must live in a separate Mix path: dep so their BEAMs are on disk first. Prefer use RpcElixir.Handler unless you have a specific reason for the split.

Type sources

Procedure types come from a compiled module's BEAM debug info — no compile-time macro is required.

  • RpcElixir.Types.FromSpec (recommended) reads classic @spec declarations via Code.Typespec.fetch_specs/1. Users write @spec next to their handlers; FromSpec reads them at runtime.

  • RpcElixir.Types.FromInferred (experimental) reads signatures inferred by Elixir's set-theoretic type system from the ExCk BEAM chunk. Lossy by design (most arg types come back as dynamic), depends on private compiler internals that change every minor release. Useful for tracking the new type system as a public introspection API stabilizes.

    To use this backend, enable inference in your own mix.exs — compiler options don't propagate from a dependency, so the target modules must be compiled with this option set:

    defmodule MyApp.MixProject do
      use Mix.Project
    
      Code.compiler_options(infer_signatures: true)
    
      def project, do: [...]
    end

Errors

Handler errors are typed. The @spec declares the error shape, the dispatcher promotes the runtime value to an %RpcError{}, the codegen turns it into RpcError<Code, Details> on the TypeScript side, and the client throws an RpcError instance you can catch and discriminate by code.

Supported error shapes

# 1. Bare atom union — code only.
@spec get(input(), ctx()) :: {:ok, user()} | {:error, :not_found | :forbidden}
def get(_, _), do: {:error, :not_found}

# 2. Map with :code (atom union) and optional :message and extra detail fields.
@spec update(input(), ctx()) ::
        {:ok, user()}
        | {:error, %{code: :not_found | :email_taken, message: String.t(), field: String.t() | nil}}
def update(_, _), do: {:error, %{code: :email_taken, message: "in use", field: "email"}}

Wire format

The dispatcher pulls :code and :message to the top of the JSON envelope; everything else ends up under details:

{:error, %{code: :email_taken, message: "in use", field: "email"}}

HTTP 400 {"error": {"code": "email_taken", "message": "in use", "details": {"field": "email"}}}

Generated TypeScript

import { RpcError } from "@elixir-ts-rpc/client";

export type UsersUpdateError = RpcError<
  "not_found" | "email_taken",
  { field: string | null }
>;

try {
  await client.users.update({ id, email });
} catch (err) {
  if (err instanceof RpcError) {
    err.code;          // "not_found" | "email_taken" | …
    err.message;       // human-readable string (also surfaces in stack traces)
    err.details?.field; // typed extras
  }
}

Status codes

Typed errors default to HTTP 400. Framework-emitted errors carry their own status (401 unauthorized, 403 forbidden, 404 procedure_not_found, 500 for output_validation_failed / handler_error). To override, return a %RpcError{status: 422} from your handler.

Typed-error :message and :details are sent to the client verbatim

Whatever a handler puts in a typed error's :message and the extra detail fields is serialized to the client as-is. This is not gated by the :expose_error_details config — that flag only redacts the framework-generated diagnostics on the unexpected-return / raised-exception paths (which become :handler_error). For your own typed errors, never put internal diagnostics (stack traces, SQL, secrets) in :message or :details; treat both as client-facing.

details values must be JSON-native

The library serializes errors with Elixir 1.18+'s built-in JSON module, which does not auto-encode Date, DateTime, NaiveDateTime, Time, or Decimal. Placing any of those types in details raises at serialization time — a runtime failure on the error path, not a compile-time check. Pre-convert them to strings or numbers before building the details map.

# bad — raises at runtime
{:error, %{code: :expired, details: %{at: ~U[2026-01-01 00:00:00Z]}}}

# good
{:error, %{code: :expired, details: %{at: DateTime.to_iso8601(~U[2026-01-01 00:00:00Z])}}}

Anything else falls through

Returns that aren't an atom, an %RpcError{}, or a map with :code (e.g. {:error, {:bobo, :gaga}}, {:error, [code: :foo]}, {:error, "string"}) are treated as framework bugs: code becomes :handler_error, status is 500, and the original value is inspect-ed into details.reason so JSON serialization can't blow up. Type your errors explicitly to avoid this path.

Documentation

  • Supported types — inline shorthand, @spec AST forms, Ecto field mappings, custom types, and the pagination envelope.

License

MIT — see LICENSE.