The type system has two surfaces that normalize to the same internal spec shape (%{kind: ...}):

  • Inline shorthand — passed directly to RpcElixir.Types.resolve/1, validate/2, serialize/2.
  • @spec typespecs — read from a compiled module's BEAM debug info by RpcElixir.Types.FromSpec. Users write @spec next to their handlers; no compile-time macro captures AST. The internal walker (Types.from_typespec/2) translates the recovered AST into %{kind: ...} and supports the typespec forms below.

An experimental third surface — RpcElixir.Types.FromInferred — reads signatures from Elixir's set-theoretic type inference (ExCk BEAM chunk). It bypasses the typespec walker entirely and is lossy by design; see the module docs for caveats.

FromInferred limitations

The inferred backend translates Module.Types.Descr terms directly into the internal kind shape. It only recognizes a narrow subset; everything else collapses to %{kind: "dynamic"}. Known gaps versus FromSpec:

  • Argument types — usually dynamic unless the function pattern-matches or guards on input.
  • Lists ([T], list(T)) — not represented in Descr bitmaps; collapse to dynamic.
  • T | nil (nullable) — not recovered as nullable; falls through to dynamic.

  • {:optional, T} / optional map keys — map openness is ignored; all known fields look required.
  • Struct types (%Mod{...}) — indistinguishable from plain maps; no :struct tag is attached.
  • Date.t(), DateTime.t(), NaiveDateTime.t(), Time.t(), Decimal.t() — seen as generic maps, not as date / datetime / decimal.
  • Ecto schemas (Mod.t()) — not resolved; only the inferred map shape, no field-type derivation.
  • Custom types (RpcElixir.CustomType) — not resolved; wire_spec/0 is never consulted.
  • Atom-literal enums (:a | :b) — recovered as enum from Descr's atom union (this case works).

  • integer() | float() union — widened to primitive / float.

  • RPC return {:ok, T} | {:error, E} — inference proves only the {:ok, T} branch unless the body explicitly returns {:error, _}; fetch_rpc/2 reports {:error, {:invalid_return, _}} for anything else.

  • Parameterized local types, any(), term() — become dynamic instead of raising.

In practice, FromInferred is only useful for handlers whose argument is a pattern-matched struct/map and whose return is a single {:ok, T} tuple over primitives, atoms, and nested maps. Anything richer should use FromSpec.

Inline shorthand

SpecInternal kind
:stringprimitive / string
:integerprimitive / integer
:floatprimitive / float
:booleanprimitive / boolean
{:optional, t}optional
{:nullable, t}nullable
{:list, t}list
{:stream, t}list (alias)
%{key: t, ...} (plain map)object
%{kind: ...} (already-resolved)passthrough

From @spec typespec AST

Typespec formResolved kind
String.t(), binary()primitive / string
integer(), non_neg_integer(), pos_integer()primitive / integer
float(), number()primitive / float
boolean()primitive / boolean
Date.t()date
DateTime.t()datetime
NaiveDateTime.t()naive_datetime
Time.t()time
Decimal.t()decimal
[T], list(T)list
T | nilnullable
:foo | :bar | :bazenum (atom-literal union)
:foo (single literal)enum with one value
%{key: T, ...}object (all required)
%{required(:k) => T}object field (required)
%{optional(:k) => T}object field (optional)
%Mod{field: T, ...}object with :struct => Mod
Mod.t() where Mod defines @type tresolved from @type t
Mod.t() where Mod is an Ecto schemaderived from __schema__/1
Mod.t() where Mod implements RpcElixir.CustomTypecustom
local_alias(T)expanded from local @type (parameterized)

Ecto field type mapping

When Mod.t() resolves to an Ecto schema, fields are derived from module.__schema__(:type, name):

Ecto typeResolved kind
:string, :binary_idprimitive / string
:id, :integerprimitive / integer
:floatprimitive / float
:booleanprimitive / boolean
:datedate
:utc_datetime, :utc_datetime_usecdatetime
:naive_datetime, :naive_datetime_usecnaive_datetime
:timetime
:decimaldecimal
{:array, T}list of T
:maprejected — use embedded schema or explicit %{...}

Explicitly rejected (with actionable errors)

  • any(), term() — every field must have an explicit type.
  • map() — use an explicit map shape like %{key: T, ...}.
  • atom() — use a literal atom or atom-literal union for enums.
  • Non-atom non-nullable unions (e.g. String.t() | integer()).

Error details

Typed errors carry a :message and optional extra detail fields (see the Errors section of the library README). Two caveats apply to what you put there:

  • Sent to the client verbatim. Everything in a typed error's :message and 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. Never put internal diagnostics (stack traces, SQL, secrets) in a typed error's :message/:details; treat both as client-facing.
  • details values must be JSON-native. Errors are serialized with Elixir 1.18+'s built-in JSON module, which does not auto-encode Date, DateTime, NaiveDateTime, Time, or Decimal. Putting any of those in details raises at serialization time — a runtime failure on the error path, not a compile-time check. Pre-convert to strings or numbers first.
# 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])}}}

On the TypeScript side, the generated RpcError.details type reflects the declared detail fields but is best-effort: at runtime details may be undefined (framework-level errors carry no domain details), so guard access with e.details?.field.

Custom types

Implement RpcElixir.CustomType on any module to make Mod.t() valid in specs. The behaviour requires wire_spec/0 (the resolved internal spec for the wire format) and serialize/1 (turn a value into something JSON-encodable).

defmodule MyApp.Money do
  @behaviour RpcElixir.CustomType
  defstruct [:amount, :currency]

  @impl true
  def wire_spec, do: %{kind: "primitive", type: "string"}

  @impl true
  def serialize(%__MODULE__{amount: a, currency: c}), do: "#{a} #{c}"
end

Branded TS types

Implement the optional ts_type/0 callback to emit a branded TypeScript type instead of the plain wire type. The brand keeps callers from accidentally treating the wire value as an untagged primitive. Branding works for both string and number wires — codegen emits string & { __brand } or number & { __brand } respectively, so wire_spec/0 must resolve to %{kind: "primitive", type: "string" | "integer" | "float"}.

defmodule MyApp.Int64 do
  @behaviour RpcElixir.CustomType

  @impl true
  def wire_spec, do: %{kind: "primitive", type: "string"}

  @impl true
  def serialize(int) when is_integer(int), do: Integer.to_string(int)

  @impl true
  def ts_type, do: "Int64String"
end

Built-in: RpcElixir.UnixMillis / EpochMillis

RpcElixir.UnixMillis is a built-in branded-number custom type. It serializes a DateTime as epoch milliseconds (integer wire) and emits the TypeScript brand EpochMillis (number & { __brand }), preventing callers from passing a bare number where a timestamp is expected.

Per-field — annotate individual spec fields with RpcElixir.UnixMillis.t():

@spec get_event(input(), ctx()) :: {:ok, %{occurred_at: RpcElixir.UnixMillis.t()}}

Project-wide via wire_aliases — use the wire_aliases router option to remap every DateTime in the router without per-field annotation. Aliases are applied at compile time, so codegen and runtime serialization agree automatically.

defmodule MyApp.Router do
  use RpcElixir.Router, wire_aliases: [{DateTime, RpcElixir.UnixMillis}]

  procedure "events.get", &MyApp.Handlers.Events.get/2
end

Each {source, target} pair in wire_aliases maps the source module's .t() to a RpcElixir.CustomType target. The target must implement the RpcElixir.CustomType behaviour.