Persist and rehydrate
Problem: you need to write an entity to a database and read it back, without the storage layer knowing about entity internals.
Snippets below assume these imports:
tsimport { z } from "zod"; import { P } from "unthrown"; import { Entity } from "@btravstack/entity";
Write with toJSON()
await db.insert("organizations", org.toJSON());toJSON() projects exactly output's keys. It never includes _tag, and never includes fields your class body declares:
class Organization extends Entity("Organization")({ id: OrgId, slug: Slug }) {
cachedSummary = "";
}
const org = Organization.make(row).getOrThrow();
org.cachedSummary = "computed"; // fine — not declared data
org.toJSON(); // { id, slug } — cachedSummary is not thereDo not use spread. { ...org } copies own enumerable properties, which includes class-body fields — so it leaks exactly what toJSON() excludes.
The projection is typed DeepReadonly, and that is honest rather than cautious: the top-level object is fresh, but nested containers are the instance's own frozen references, so mutating one would throw. A driver that insists on mutating its argument gets a structural clone (structuredClone(org.toJSON())), not a cast.
Read with make()
const org = Organization.make(row).getOrThrow();make is the only way in, and it is the same entry point for a database row, a folded event stream, an untrusted import, or a replayed integration event. They differ in where the data came from, not in what has to happen to it: validate, re-derive the computed fields, check the invariants, construct.
Extra keys are ignored, so a row carrying computed columns round-trips unchanged:
Organization.make(org.toJSON()); // ✓ round-tripsComputed columns heal themselves
Store computed fields if you need to index or query them — toJSON() includes them. On read they are re-derived, not trusted:
// a row written before the derivation changed, or before the field existed
Person.make({ id, first: "Ada", last: "Lovelace", fullName: "stale value" });
// ^ ignored and recomputedThat means a derivation change does not need a backfill migration to be correct — only to make stored values match, for queries that read the column directly. For every other kind of model change against stored rows — adding, defaulting, renaming, retiring a field — see Evolve an entity.
Map a repository
The type helpers name each shape, so a repository signature never restates the model:
type OrganizationRow = Entity.Output<typeof Organization>;
interface OrganizationRepository {
save(org: Organization): Promise<void>;
findById(id: z.infer<typeof OrgId>): Promise<Organization | undefined>;
}
const repository: OrganizationRepository = {
async save(org) {
await db.upsert("organizations", org.toJSON());
},
async findById(id) {
const row: OrganizationRow | undefined = await db.findOne("organizations", {
id,
});
return row && Organization.make(row).getOrThrow();
},
};Decide what a read failure means
A row that fails validation is a real signal — the database holds data the domain considers impossible. getOrThrow() is fine when that should page someone; handle the Result when it should not:
const loaded = Organization.make(row).match({
ok: (org) => org,
errCases: (m) =>
m.with(P.tag("InvalidEntity"), (e) => {
logger.error(
{ id: row.id, issues: e.issues },
"corrupt organization row",
);
return undefined;
}),
defect: (cause) => {
throw cause;
},
});