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, plusHandler,Router,Middleware,Dispatcher, andPlug— 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"}
]
endFor 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
end3. Register it in a router
defmodule MyApp.RpcRouter do
use RpcElixir.Router
procedure "users.get", &MyApp.Handlers.Users.get/2
endEach 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
endA 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]
]
endSee 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"}
]
endName notes: the Hex package / OTP application name is
:elixir_ts_rpc(use it indeps, inconfig :elixir_ts_rpc, ..., and incompilers:). The Elixir module namespace isRpcElixir.*(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
endThe 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@specAST into a__rpc_specs__/0accessor, 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 viaCode.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 Mixpath:dep so their BEAMs are on disk first. Preferuse RpcElixir.Handlerunless 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@specdeclarations viaCode.Typespec.fetch_specs/1. Users write@specnext to their handlers;FromSpecreads them at runtime.RpcElixir.Types.FromInferred(experimental) reads signatures inferred by Elixir's set-theoretic type system from theExCkBEAM chunk. Lossy by design (most arg types come back asdynamic), 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,
@specAST forms, Ecto field mappings, custom types, and the pagination envelope.
License
MIT — see LICENSE.