Embeds ​
An embed is a structured object, or a list of them, stored inside one JSON column of its parent. It has fields, types and validations like a schema, but no table.
class Address extends EmbeddedSchema({
street: t.string(),
city: t.string(),
postCode: t.string().nullable(),
}) {
static changeset(address: Address | typeof Address, params: unknown) {
return cast(address, params, ["street", "city", "postCode"]).validateRequired(["street", "city"]);
}
}
class Phone extends EmbeddedSchema({
id: t.uuid().primaryKey().autogenerate(),
label: t.string(),
number: t.string(),
}) {
static changeset(phone: Phone | typeof Phone, params: unknown) {
return cast(phone, params, ["label", "number"]).validateRequired(["number"]);
}
}
class User extends Schema("users", {
id: t.integer().primaryKey().dbGenerated(),
email: t.string(),
address: t.embedOne(() => Address, { onReplace: "update" }).nullable(),
phones: t.embedMany(() => Phone, { onReplace: "delete" }),
}) {}t.embedOne(() => E)is one object;t.embedMany(() => E)is an array (a new record starts with an empty one).- Both are stored as
jsonbon Postgres, and as JSON text or native JSON elsewhere. - Loaded embeds are instances of their class, so getters and methods work. They are built by the class's row factory: no constructor runs.
- Embeds can contain embeds.
Changing embeds ​
// From untrusted input
cast(user, params, ["email"]).castEmbed("address", { with: Address.changeset, required: true });
// From trusted data
change(user).putEmbed("address", { street: "Karl Johans gate 1", city: "Oslo", postCode: null });castEmbed and putEmbed work like castAssoc and putAssoc: child changesets, nested errors, and with defaulting to the embed's static changeset. cast, change and putChange refuse an embed field and point to them.
- Matching in a list needs a primary key on the embed, as
Phonehas. Items are then matched by id and updated in place, and a key with.autogenerate()is filled in for new items when they are saved (or applied). Without a key,castEmbedreplaces the whole list, and a list equal to the stored one is no change. onReplaceis passed to the field:t.embedOne(() => Address, { onReplace: "update" }). The values are"error"(the default),"invalid","update"(one only) and"delete"(the item is dropped from the column).- Any change inside an embed rewrites the whole column. The parent field counts as changed, and
cs.changes.addressholds the child changeset.getField,applyChangesandapplyActiongive embedded rows instead. - A new item that would lack a value for a field that is neither nullable nor defaulted is reported as
can't be blankon that item when the parent is saved.
// params: { phones: [{ id: "…", label: "home", number: "22 00 00 00" }, { label: "work", number: "…" }] }
const saved = await repo.updateOrThrow(cast(user, params, []).castEmbed("phones"));
saved.phones[0] instanceof Phone; // true: rows of the embedded classStored form ​
Each embedded field is written with its own type, then as JSON: a timestamp is stored as an ISO string and read back as a Date, a bigint as a string of digits, bytes as base64. A plain object written directly (by insertAll, say) is first made an embedded row: its missing fields take their defaults and generated values, its values are cast, and a key that is not a field is refused (CastError).
When reading, keys that are not fields are ignored and missing keys get the field's default (or null for a nullable field); a stored null in a field that is not nullable counts as missing. A missing key of a field that has neither, or a stored value of the wrong JSON kind (a string where a number is expected), is an error (CastError): the database does not check what is inside a JSON column. Give a field a default when you add it to an embed that already has stored rows.
A .redact() field inside an embed hides the whole embed column's value in logs.
The JSON keys are the field names as written: postCode is stored as "postCode", not "post_code". .column("pc") renames one key, and EmbeddedSchema(fields, { naming: "snake_case" }) switches the whole embed to snake_case keys.
Querying inside an embed ​
const inOslo = await repo.all(from(User, "u").where(({ u }) => u.address.field("city").eq("Oslo")));field(name) is typed from the embed (Expr<string> here; nullable when the embed or the field is). It reads the field's JSON key as text, cast to the field's type where that is not text (a number, a boolean, a date or timestamp from its ISO string), and chains into a nested embed: u.card.field("point").field("lat"). It works on t.embedOne fields. Bytes (stored as base64) and lists (stored as JSON arrays) are refused, as are lists of embeds and other JSON: use the general JSON operators path and pathText for those; see Expressions.
When to use an embed ​
Use an embed for data that belongs to exactly one parent and is always read with it: an address, preferences, a list of phone numbers. Use an association when the data is shared, queried by itself, or large.
Good to know
- One column. Any change inside an embed rewrites the whole JSON column.
- Keys as written. JSON keys are the field names (
postCode), not snake_case, unless the embed saysnaming: "snake_case". - No constraint inside. The database does not check what is inside a JSON column: give a field a default when you add it to an embed that already has stored rows.
See also ​
- Schemas: embedded schemas
- Nested changesets:
castAssocandputAssoc, whichcastEmbedandputEmbedmirror. - Expressions: JSON

