Skip to main content

README.md

# rpc_elixir

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

Part of [elixir-ts-rpc](https://github.com/ostatni5/elixir-ts-rpc) — a typed RPC layer between Elixir servers and TypeScript clients.

📖 **[Full guide & documentation → ostatni5.github.io/elixir-ts-rpc](https://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](CHANGELOG.md) 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](https://hex.pm/packages/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:

```elixir
# 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](#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](#handler-compilation)).

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

```elixir
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

```elixir
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`:

```elixir
# .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

```elixir
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`:

```elixir
# config/config.exs
config :elixir_ts_rpc,
  router: MyApp.RpcRouter,
  out: Path.expand("../assets/src/rpc.gen.ts", __DIR__)
```

```elixir
# mix.exs
def project do
  [
    # ...
    compilers: Mix.compilers() ++ [:elixir_ts_rpc]
  ]
end
```

See [Choosing a codegen workflow](https://github.com/ostatni5/elixir-ts-rpc#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:

```ts
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`](https://github.com/ostatni5/elixir-ts-rpc/blob/main/packages/client/README.md) for catching typed
errors, abort signals, and cross-origin auth.

## Installation

The package is on Hex — see [Add the dependency](#1-add-the-dependency) above.
For monorepo/local development, use a path or GitHub dep instead:

```elixir
# same umbrella / monorepo
def deps do
  [
    {:elixir_ts_rpc, path: "../rpc_elixir"}
  ]
end
```

```elixir
# 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](#handler-compilation)):

```elixir
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:

```elixir
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 `@spec`s 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:

  ```elixir
  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

```elixir
# 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`:

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

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

### Generated TypeScript

```ts
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.

```elixir
# 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](docs/supported-types.md) — inline shorthand, `@spec` AST
  forms, Ecto field mappings, custom types, and the pagination envelope.

## License

MIT — see [LICENSE](https://github.com/ostatni5/elixir-ts-rpc/blob/main/apps/rpc_elixir/LICENSE).