A serializer module does two jobs:

  1. At runtime, serialize/2 turns a struct or a map into a JSON-ready map.
  2. At generation time, mix typelizer.gen writes a TypeScript interface for it.

Both jobs read the same declaration, so the data and its type cannot drift.

defmodule MyAppWeb.TaskSerializer do
  use Typelizer.Serializer, schema: MyApp.Tasks.Task

  attributes [:id, :status, :due_on, :inserted_at]

  # The column is nullable, but the API always sends a title.
  attribute :title, nullable: false

  # A computed attribute must declare `type:`.
  attribute :overdue, type: :boolean, value: &MyApp.Tasks.overdue?/1

  # An arity-2 function gets the options given to `serialize/2`.
  attribute :can_edit,
    type: :boolean,
    value: fn task, opts -> task.owner_id == opts[:user_id] end

  has_one :assignee, serializer: MyAppWeb.UserSerializer, nullable: true
  has_many :comments, serializer: MyAppWeb.CommentSerializer
end

use Typelizer.Serializer

OptionDefaultMeaning
schema:nilAn Ecto schema or embedded schema. Typelizer infers types from it and checks that each attribute exists.
name:derivedThe TypeScript interface name. See Names.
key_transform:config :typelizer, key_transform: (:camel):camel (due_on → dueOn) or :snake (keys stay as written).

Fields

Fields appear in the output and in the interface in declaration order. A field declared twice is a compile error.

attributes/1

attributes [:id, :title, :status]

Schema fields. It needs schema:. Each name must be a field of the schema. An association in attributes is a compile error: use has_one or has_many. An embed (embeds_one, embeds_many) is allowed and becomes a nested object.

attribute/2

attribute :title
attribute :title, nullable: false
attribute :priority, type: {:enum, [:low, :high]}
attribute :overdue, type: :boolean, value: &MyApp.Tasks.overdue?/1
OptionMeaning
type:A type spec. Required when the field has value: or the serializer has no schema:. It replaces the inferred type.
nullable:true or false. Always wins. See Nullability.
value:A function that computes the value: arity 1 (record) or arity 2 (record, opts). Anonymous functions and captures work.
optional:true leaves the key out when the value is nil. TypeScript: key?: T. See Optional keys.
required:For an embed: the fields that are never null, or :all. See Nullability.
if:A function (arity 1 or 2, like value:) that decides whether the key is sent. TypeScript: key?: T.

The three-argument form takes the function as the last argument. The options must be in brackets, because Elixir allows a keyword list only as the last argument:

attribute :overdue, [type: :boolean], fn task -> MyApp.Tasks.overdue?(task) end

has_one/2 and has_many/2

has_one :assignee, serializer: MyAppWeb.UserSerializer, nullable: true
has_many :comments, serializer: MyAppWeb.CommentSerializer
has_one :owner, serializer: MyAppWeb.UserSerializer, value: fn task -> task.project.owner end
OptionMeaning
serializer:Required. The serializer module for the nested value(s).
nullable:has_one only. true or false.
value:A function (arity 1 or 2) that returns the nested value(s) instead of record.<name>.
optional:true leaves the key out when the value is nil.
if:A function (arity 1 or 2) that decides whether the key is sent.

A has_one can point to an association (belongs_to, has_one) or to an embeds_one. A has_many can point to a has_many, a many_to_many or an embeds_many. With value:, the name does not have to exist on the schema.

Serializers may refer to each other in cycles (Task → User → Task). A serializer reference is not a compile-time dependency.

Optional keys

Two options make a key optional (key?: in TypeScript):

# Sent only when the value is not nil: `archivedNote?: string`.
attribute :archived_note, type: :string, optional: true, value: &archived_note/1

# Sent only when the condition holds: `internalRank?: number | null`.
attribute :internal_rank, type: {:nullable, :integer}, if: fn _task, opts -> opts[:admin] end

# Guard an association that is not always preloaded: `project?: Project`.
has_one :project, serializer: MyAppWeb.ProjectSerializer, if: &Ecto.assoc_loaded?(&1.project)
  • With optional: true, the value is never null in the output, so the type has no | null. A has_many with optional: true leaves the key out for nil and sends [] as [].
  • With if:, the value is computed only when the condition holds. The type keeps its nullability: key?: T | null when the value can be nil.
  • value: and if: must be written inline (an anonymous function or a capture), not passed in a variable.

Runtime

MyAppWeb.TaskSerializer.serialize(task)
MyAppWeb.TaskSerializer.serialize(task, user_id: current_user.id)
MyAppWeb.TaskSerializer.serialize_many(tasks)
MyAppWeb.TaskSerializer.serialize_many(tasks, user_id: current_user.id)
MyAppWeb.TaskSerializer.serialize(nil)
#=> nil
  • The keys are strings, after the key transform.
  • The values match the TypeScript type:
    • Date, Time, NaiveDateTime and DateTime → ISO-8601 strings.
    • Decimal → a string ("12.50"), or a float with decimal_type: :number.
    • Ecto.Enum atoms → strings.
    • Embeds → maps with transformed keys.
    • Values typed :map, :any, :unknown, {:union, …}, {:intersection, …} or {:ts, …} pass through unchanged.
  • The options go down to nested serializers.
  • An association that is not preloaded raises Typelizer.SerializationError with the serializer, the field and a hint to preload it.
  • The field list, the keys and the encoders are built at compile time. serialize/2 does no schema reflection per call.

Type inference from Ecto

For a schema field without type:, Typelizer reads the Ecto type:

Ecto typeTypeScript
:id, :integer, :floatnumber
:binary_id, Ecto.UUID, :string, :binarystring
:booleanboolean
:decimalstring (number with decimal_type: :number)
:date, :time, :naive_datetime, :utc_datetime (and _usec)string
:mapRecord<string, unknown>
:any (virtual fields)unknown
{:map, t}Record<string, T>
{:array, t}T[]
Ecto.Enuma literal union: "todo" | "doing" | "done"
{:array, Ecto.Enum}("a" | "b")[]
embeds_onea nested object with every field of the embedded schema
embeds_manyan array of that nested object
a custom Ecto.Typeinferred from its type/0 (see below)

A type that Typelizer cannot map is a compile error that asks you to add type:.

Custom types with struct values

For a custom Ecto.Type, Typelizer reads type/0: the type of the database column. When the value in the struct is a struct of its own (for example a Money type stored as a string or an integer), that type is wrong for JSON: the TypeScript type says string, and the struct passes through unchanged, so the JSON encoder fails or sends a different shape. Declare the type and convert the value yourself:

attribute :price, type: :string, value: fn product -> Money.to_string(product.price) end

attribute :price,
  type: {:object, amount: :integer, currency: :string},
  value: fn product -> %{amount: product.price.amount, currency: product.price.currency} end

Type specs

Type specs are used by type: and by Inertia page props.

SpecTypeScript
:string, :binary_id, :uuid, :binarystring
:integer, :float, :number, :idnumber
:booleanboolean
:decimalstring (number with decimal_type: :number)
:date, :time, :naive_datetime, :utc_datetime, :datetime (and _usec)string
:mapRecord<string, unknown>
:anyany
:unknownunknown
{:list, spec} or {:array, spec}T[]
{:map, spec}Record<string, T>
{:enum, values} (atoms, strings or integers)"a" | "b" or 1 | 2
{:nullable, spec}T | null
{:object, [key: spec, …]}{ key: T; … } (keys transformed)
{:optional, spec}inside {:object, …} and page props only: key?: T
{:union, [spec, …]}A | B
{:intersection, [spec, …]}A & B
{:ts, "raw TypeScript"}the text as written
{:ts, "raw TypeScript", [Serializer, …]}the text as written, with imports for the named serializers
{:envelope, spec}Envelope<T>
{:envelope, spec, meta_spec}Envelope<T, M>
{:paginated, item_spec}Paginated<T>
{:cursor_paginated, item_spec}CursorPaginated<T>
a serializer moduleits interface (imported)

An invalid type spec is a compile error. Inside a list, a union, an intersection or raw TypeScript with operators gets parentheses: (A | B)[].

attribute :subject,
  type: {:union, [MyAppWeb.UserSerializer, MyAppWeb.ProjectSerializer]},
  value: &subject/1

attribute :card,
  type: {:ts, "Omit<User, \"role\"> & { online: boolean }", [MyAppWeb.UserSerializer]},
  value: &card/1

The serializer does not change values typed {:union, …}, {:intersection, …} or {:ts, …}: it cannot know which member a value is. Return JSON-ready values from value:, for example by calling the right serializer yourself:

defp subject(%{subject: %MyApp.Accounts.User{} = user}), do: MyAppWeb.UserSerializer.serialize(user)
defp subject(%{subject: project}), do: MyAppWeb.ProjectSerializer.serialize(project)

Envelopes and pagination

APIs often wrap data in an envelope, { "data": …, "meta": … }, and paginated lists add paging metadata. Typelizer.Envelope builds these values, and four type specs describe them:

json(conn, Typelizer.Envelope.wrap(MyAppWeb.TaskSerializer.serialize_many(tasks)))

json(conn, Typelizer.Envelope.paginated(
  MyAppWeb.TaskSerializer.serialize_many(page.entries),
  page: page.page_number, page_size: page.page_size, total: page.total_entries
))

json(conn, Typelizer.Envelope.cursor_paginated(
  MyAppWeb.TaskSerializer.serialize_many(entries),
  next_cursor: next, previous_cursor: previous
))
page "tasks/index", props: [tasks: {:paginated, MyAppWeb.TaskSerializer}]

attribute :recent,
  type: {:envelope, {:list, MyAppWeb.CommentSerializer}, {:object, generated_at: :utc_datetime}},
  value: &recent_comments/1

When a serializer or a page uses one of these specs, mix typelizer.gen writes Envelope.ts in the serializer directory (and index.ts exports its types):

export interface Envelope<T, M = Record<string, unknown>> {
  data: T;
  meta?: M;
}

export interface PaginationMeta {
  page: number;
  pageSize: number;
  total: number;
  totalPages: number;
}

export interface Paginated<T> {
  data: T[];
  meta: PaginationMeta;
}

export interface CursorMeta {
  nextCursor: string | null;
  previousCursor: string | null;
}

export interface CursorPaginated<T> {
  data: T[];
  meta: CursorMeta;
}
  • The data is not changed: serialize it first, as above. Values typed with these specs pass through the serializer unchanged.
  • The meta keys follow the key transform, at every level (page_size → pageSize). In a serializer with key_transform: :snake, the meta spec of {:envelope, spec, meta_spec} has snake_case keys: build the value with Typelizer.Envelope.wrap(data, meta, key_transform: :snake). The Paginated and CursorPaginated meta types always follow the global key transform, like paginated/2 and cursor_paginated/2. paginated/2 needs page: (from 1), page_size: and total:, and computes totalPages.
  • For a pagination library, pass its numbers: for Scrivener, page.page_number, page.page_size and page.total_entries; for Flop, meta.current_page, meta.page_size and meta.total_count.
  • A serializer named Envelope, Paginated, CursorPaginated, PaginationMeta or CursorMeta fails the generation when the envelope types are used.

Nullability

The first rule that applies wins:

  1. An explicit nullable: option, or a {:nullable, spec} type. With optional: true the field is never null (the key is left out instead).
  2. A field with its own type: (a computed attribute, or any attribute with type:) is not nullable.
  3. The primary key and the timestamps() fields are not nullable. A has_many is never nullable.
  4. When config :typelizer, repo: MyApp.Repo is set, mix typelizer.gen reads information_schema.columns in PostgreSQL. NOT NULL columns are not nullable. For a has_one over a belongs_to, the foreign key column decides. For a has_one over an embeds_one, the embed column decides. The database must be reachable, or the task fails.
  5. Otherwise the field is nullable.

Fields inside an inline embed have no database column: only the primary key is not nullable, and every other field is nullable. List the fields that are never null with required:. A nested embed takes its own list:

# address: { street: string; line2: string | null; city: string; geo: { lat: number; lng: number } } | null
attribute :address, required: [:street, :city, geo: [:lat, :lng]]

# price: { amount: number; currency: string }
attribute :price, required: :all

required: changes the fields inside the embed. The embed itself follows the rules above (here, its database column). :all covers the direct fields; a nested embed keeps its own defaults unless you list it. For full control, write a serializer for the embedded schema and use has_one or has_many.

Names

The default interface name comes from the module name. Typelizer drops the first segment (your app or web namespace), drops a Serializers segment and drops the Serializer suffix. It joins the rest:

ModuleInterface
MyAppWeb.TaskSerializerTask
MyAppWeb.Admin.TaskSerializerAdminTask
MyApp.Serializers.TaskSerializerTask

Change it with use Typelizer.Serializer, name: "TaskDetail" or with config :typelizer, type_names: %{MyAppWeb.TaskSerializer => "TaskDetail"}. The config wins. Two serializers with the same name fail the generation.

Generated TypeScript

One file per serializer, named after the interface, plus index.ts:

// assets/js/generated/serializers/Task.ts
// Generated by typelizer — do not edit.
// Source: MyAppWeb.TaskSerializer

import type { Comment } from "./Comment";
import type { User } from "./User";

export interface Task {
  id: string;
  status: "todo" | "doing" | "done";
  dueOn: string | null;
  insertedAt: string;
  title: string;
  overdue: boolean;
  canEdit: boolean;
  assignee: User | null;
  comments: Comment[];
}
// assets/js/generated/serializers/index.ts
// Generated by typelizer — do not edit.

export type { Comment } from "./Comment";
export type { Task } from "./Task";
export type { User } from "./User";

An embed renders as a nested object:

export interface Project {
  id: string;
  settings: {
    theme: "light" | "dark" | null;
    notifyOnDone: boolean | null;
  } | null;
}

Compile-time errors

Typelizer raises a CompileError with a hint for:

  • attributes or attribute without schema: and without type:;
  • value: without type:;
  • a field that is not in the schema;
  • an association in attributes;
  • has_one or has_many without serializer:;
  • a field declared twice;
  • an invalid type spec;
  • an Ecto type that Typelizer cannot map.

mix typelizer.gen fails when a serializer: or a type spec names a module that does not use Typelizer.Serializer.

Introspection

MyAppWeb.TaskSerializer.__typelizer__(:name), (:schema), (:fields) and (:key_transform) return the compiled metadata. The generator uses them; you normally do not.