elixir_ts_rpc
Typed RPC between Elixir and TypeScript. One @spec is the whole contract.
It validates requests at runtime and generates your TypeScript client. There is
no second schema to keep in sync.
๐งช Playground โ edit a @spec, watch the client regenerate
runs this codegen in your browser. Nothing to install.
How it looks
Write a handler with a normal @spec:
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: MyApp.Users.fetch(id)
end
Expose the module on a router:
defmodule MyApp.RpcRouter do
use RpcElixir.Router
scope "users" do
expose MyApp.Handlers.Users # "users.get", "users.list", ...
end
end
Every public, @spec'd, arity-2 function of the module is published, named after
the function. Add another one to the handler and it appears on the next compile.
When you need a different wire name, a subset of a module, or per-function
middleware, name procedures one at a time with procedure instead. See
RpcElixir.Router.
mix rpc.gen.ts reads that spec from BEAM debug info. It then writes a typed client:
const user = await client.users.get({ id: 1 });
// ^? { id: number; name: string }
Why
- No schema DSL. No GraphQL SDL, no OpenAPI document, no Zod mirror, no
macro. The
@specyou would write anyway is the schema. - A module is an API surface.
exposepublishes a whole handler module, so the router does not grow a line per function. Explicitprocedurecalls are there when you want the surface pinned down by hand. - The spec is enforced. The dispatcher validates input and output against it on every request. The TypeScript types are not a hopeful annotation.
- Errors are typed too. Every
{:error, reason}branch becomes aDomainErrorthe client catches and narrows. - The client points back at your code. Every generated method carries a link to the handler line that produced it. Hover a call in TypeScript, follow the link, land on the Elixir function.
- Fits your Phoenix app. Mount one plug in your existing endpoint. Keep
mix phx.gen.authand CSRF exactly as they are.
Weighing it against Absinthe, OpenAPI codegen, LiveView, or hand-written endpoints? See Comparison.
Scope
Pre-1.0 (0.0.2), so APIs may change. You need Elixir ~> 1.17 on OTP 26+.
Elixir 1.19+ is recommended. On 1.17 add {:jason, "~> 1.4"} to your deps, since
Elixir's built-in JSON module only arrived in 1.18.
Transport is HTTP request/response only. There is no SSE, Channels, or WebSocket
transport yet, and React is the only framework adapter. Full list:
what works today.
Install
# mix.exs
def deps do
[{:elixir_ts_rpc, "~> 0.0.2"}]
end
The Hex package and OTP app are :elixir_ts_rpc. The module namespace is
RpcElixir.*.
Next: Getting started.
Documentation
The server side lives here on HexDocs:
- Getting started
- Plug options: everything
RpcElixir.Plugaccepts - Writing middleware: the
RpcElixir.Middlewarebehaviour - Supported types: how
@specmaps to TypeScript - Handling errors: error shapes, wire format, status codes
- Custom types: branded wires,
RpcElixir.UnixMillis,wire_aliases - Module reference:
RpcElixir.Router,RpcElixir.Plug,RpcElixir.Handler,RpcElixir.Middleware,RpcElixir.CustomType.
The client side lives on the guide site:
- How it works: the request lifecycle
- Comparison: versus Absinthe, OpenAPI, LiveView, gRPC
- Using the client:
@elixir-ts-rpc/client - React + TanStack Query:
@elixir-ts-rpc/reacthooks - Codegen workflows: compiler hook, watcher, CI task
- Playground: edit a
@specin the browser and watch the client regenerate, with the real codegen compiled to WebAssembly - Examples: runnable Plug and Phoenix apps
License
MIT.