You write ordinary Elixir types. Codegen resolves each one to an internal kind (%{kind: ...}). The TypeScript then follows from the kind. integer and float both become number. nullable becomes T | null. The tables below give the resolved kind, not the emitted TypeScript.

Two surfaces, same kinds:

  • Inline shorthand, passed to RpcElixir.Types.resolve/1, validate/2, or serialize/2.
  • @spec typespecs. RpcElixir.Types.FromSpec reads them from BEAM debug info via Code.Typespec. No type macro is needed. use RpcElixir.Handler can also capture the AST at compile time.

RpcElixir.Types.FromInferred is an experimental third surface. It is lossy and not recommended. See its module docs.

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)

Recursive struct types are supported. Self-referential and mutually recursive ones both work. The cycle point becomes a named interface reference. A non-struct recursive type raises.

The custom kind, branded wire types, and project-wide wire_aliases live in Custom types.

Ecto field type mapping

Each field of an Ecto schema comes from module.__schema__(:type, name). Temporal and decimal fields check wire_aliases first. Take wire_aliases: [{DateTime, RpcElixir.UnixMillis}]. A :utc_datetime field then becomes that custom type. It is no longer the plain datetime kind. See Custom types.

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 an embedded schema or explicit %{}

Built-in brand names

Five kinds do not emit a plain string. Each emits a branded alias. The shape is string & { readonly __brand: "Name" }. A brand blocks mixing it with any string.

KindTypeScript brandBase type
dateDateStringstring
datetimeDateTimeStringstring
naive_datetimeNaiveDateTimeStringstring
timeISOTimestring
decimalDecimalStringstring

A custom type can emit its own brand. Those names must not clash with these five. See Custom types.

PaginatedResponse<T>

One object shape is special-cased. These exact three fields are detected:

%{items: [T], next_cursor: String.t() | nil, has_more: boolean()}

Codegen emits a shared generic type. An inline object type is not used:

export type PaginatedResponse<T> = { items: T[]; next_cursor: string | null; has_more: boolean };

// a procedure output of that shape renders as:
PaginatedResponse<User>

The match is exact. next_cursor must be a nullable string. has_more must be a boolean. A fourth field breaks the match. So does a different field type.

Objects are closed

An object rejects any field the spec omits. Each extra field gives an "unexpected field" error. On input that means input_validation_failed, status 400. On output it means output_validation_failed, status 500. Declare the field, or mark it optional. Or stop sending it.

Coercions during validation

Validation converts a few wire forms:

  • An integer is accepted for a float field. It widens to a float.
  • ISO 8601 strings parse into dates and times. That covers Date, DateTime, NaiveDateTime, and Time.
  • A decimal string parses into a Decimal.
  • An enum string becomes an atom. Only an existing atom is accepted.

Explicitly rejected

These raise an error. They do not degrade silently.

TypeUse instead
any(), term()an explicit type for every field
map()an explicit shape, %{key: T, ...}
atom()a literal atom or atom-literal union
non-atom non-nullable unions (String.t() | integer())one concrete type

atom() has no clause of its own. Its error asks for a local @type. That message is misleading. Use a literal atom or an atom union.