Current section
Files
Jump to
Current section
Files
guides/serializers.md
# Serializers
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.
```elixir
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`
| Option | Default | Meaning |
|---|---|---|
| `schema:` | `nil` | An Ecto schema or embedded schema. Typelizer infers types from it and checks that each attribute exists. |
| `name:` | derived | The TypeScript interface name. See [Names](#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`
```elixir
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`
```elixir
attribute :title
attribute :title, nullable: false
attribute :priority, type: {:enum, [:low, :high]}
attribute :overdue, type: :boolean, value: &MyApp.Tasks.overdue?/1
```
| Option | Meaning |
|---|---|
| `type:` | A [type spec](#type-specs). 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](#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](#optional-keys). |
| `required:` | For an embed: the fields that are never null, or `:all`. See [Nullability](#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:
```elixir
attribute :overdue, [type: :boolean], fn task -> MyApp.Tasks.overdue?(task) end
```
### `has_one/2` and `has_many/2`
```elixir
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
```
| Option | Meaning |
|---|---|
| `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):
```elixir
# 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
```elixir
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 type | TypeScript |
|---|---|
| `:id`, `:integer`, `:float` | `number` |
| `:binary_id`, `Ecto.UUID`, `:string`, `:binary` | `string` |
| `:boolean` | `boolean` |
| `:decimal` | `string` (`number` with `decimal_type: :number`) |
| `:date`, `:time`, `:naive_datetime`, `:utc_datetime` (and `_usec`) | `string` |
| `:map` | `Record<string, unknown>` |
| `:any` (virtual fields) | `unknown` |
| `{:map, t}` | `Record<string, T>` |
| `{:array, t}` | `T[]` |
| `Ecto.Enum` | a literal union: `"todo" \| "doing" \| "done"` |
| `{:array, Ecto.Enum}` | `("a" \| "b")[]` |
| `embeds_one` | a nested object with every field of the embedded schema |
| `embeds_many` | an array of that nested object |
| a custom `Ecto.Type` | inferred 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:
```elixir
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.
| Spec | TypeScript |
|---|---|
| `:string`, `:binary_id`, `:uuid`, `:binary` | `string` |
| `:integer`, `:float`, `:number`, `:id` | `number` |
| `:boolean` | `boolean` |
| `:decimal` | `string` (`number` with `decimal_type: :number`) |
| `:date`, `:time`, `:naive_datetime`, `:utc_datetime`, `:datetime` (and `_usec`) | `string` |
| `:map` | `Record<string, unknown>` |
| `:any` | `any` |
| `:unknown` | `unknown` |
| `{: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 module | its 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)[]`.
```elixir
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:
```elixir
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:
```elixir
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
))
```
```elixir
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):
```ts
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:
```elixir
# 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:
| Module | Interface |
|---|---|
| `MyAppWeb.TaskSerializer` | `Task` |
| `MyAppWeb.Admin.TaskSerializer` | `AdminTask` |
| `MyApp.Serializers.TaskSerializer` | `Task` |
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`:
```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[];
}
```
```ts
// 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:
```ts
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.