@caverar/ts-entity
Type-safe domain entities for TypeScript with schema validation, explicit state transitions, and serialization.
Installation
npm install @caverar/ts-entity zod
TypeScript ^6.0.3 is a peer dependency. The package uses the Standard Schema interface, so you can adapt another compatible validation library instead of Zod.
Quick start
import * as z from 'zod'
import {
EntityFactory,
type EntityMetaStatusVariants,
type EntityFlatSnapshot,
type EntitySelf,
} from '@caverar/ts-entity'
const factory = new EntityFactory({
dateFormat: 'Date',
id: {
idSchema: z.uuid(),
idGenerator: () => crypto.randomUUID(),
},
fieldsSchemasGenerator: (schemas) =>
Object.assign({}, ...(schemas as any).map((schema: z.ZodObject) => schema.shape)),
mergeSchemaGenerator: (schemas) => z.union(schemas as any),
})
const UserBase = factory.createAbstractEntity({
draft: {
schema: z.object({
email: z.email(),
name: z.string().min(1),
}),
mutatesTo: ['draft', 'active'],
},
active: {
schema: z.object({
email: z.email(),
name: z.string().min(1),
activatedAt: z.date(),
}),
mutatesTo: [],
},
})
class User extends UserBase {
activate(this: EntitySelf<User, 'draft'>) {
return this.mutateEntity({ activatedAt: new Date() }, 'active')
}
}
const result = User.createEntity({ email: '[email protected]', name: 'Ada' }, 'draft')
if (!result.isOk) {
throw new Error('Invalid user data')
}
const user = result.value
console.log(user.id, user.data.email)
// Use EntityMetaStatusVariants for a value whose current domain state varies.
// `metaStatus` narrows the union to its state-specific data and methods.
function inspectUser(entity: EntityMetaStatusVariants<User>) {
if (entity.metaStatus === 'draft') {
console.log(entity.data.email)
} else {
console.log(entity.data.activatedAt)
}
}
inspectUser(user)
// A snapshot is also assignable to the merged shape accepted by restoreEntity().
const flatSnapshot: EntityFlatSnapshot<User> = user.toSnapshot()
const activation = user.activate()
if (activation.isOk) {
console.log(activation.value.metaStatus) // 'active'
}
toSnapshot() preserves the current state's shape and returns a { meta, data } union
discriminated by metaStatus. It accepts { target: 'json' } to serialize values such as dates
and bigints for JSON storage. See Choosing an output for when to use it
versus the other serialization methods.
Choosing an output
Entities expose three groups of APIs. Pick by who consumes the result.
Runtime serialization (instance methods)
| Method | Consumer | Shape | version | Brands |
|---|---|---|---|---|
toSnapshot() | Simple repositories; the inverse of restoreEntity() | { meta, data }, discriminated by metaStatus | included | removed |
toPersistenceChanges() | Repositories that write only what changed | { persistenceStatus, meta, set, unset } | included | removed |
toDTO() | Use case input/output, other bounded contexts, entry points | Flat object: meta and data at one level | never exposed | removed; keepBrands: true keeps them (types only) |
All three accept { target: 'json' }. toDTO() also accepts omit (any key except metaStatus,
which keeps unions discriminable) and never leaks the entity itself.
Construction and reconstruction (static methods)
| Method | Purpose |
|---|---|
createEntity(data, metaStatus, meta?) | Starts a new lifecycle (persistenceStatus: 'new', version: 1) |
restoreEntity({ meta, data }) | Rebuilds an unchanged baseline from a stored snapshot; data uses the merged shape |
Type helpers
| Type | Describes |
|---|---|
EntitySnapshot<E, Target, States> | Return type of toSnapshot() |
EntityFlatSnapshot<E, Target, States> | Snapshot with every state merged (state-specific fields optional); the shape of restoreEntity() input and database projections |
EntityPersistenceChanges<...> | Return type of toPersistenceChanges() |
EntityDTO<E, States, Omitted, Target> | Return type of toDTO(), unbranded; a union discriminated by metaStatus |
EntityBrandedDTO<...> | Same as EntityDTO, keeping brands (toDTO({ keepBrands: true })) |
EntityRawDTO<...> | EntityDTO flattened into one object across states, for manual DTO/type construction |
EntityPickDTO<E, States, Keys> | Picked keys of the flat DTO, for use case inputs |
type AccountResponse = EntityDTO<Account, 'active' | 'frozen', 'createdAt', 'json'>
const response: AccountResponse = account.toDTO({ target: 'json', omit: ['createdAt'] })
Persistence and optimistic concurrency
Entities expose persistenceStatus (new, updated, or unchanged) and a numeric version.
Together they let a repository choose an insert, an optimistic update, or no write. These values
describe the in-memory persistence lifecycle; they do not perform database operations themselves.
Creating a new entity
User.createEntity(data, state) validates the initial data and returns a new entity with
persistenceStatus: 'new' and version: 1. Further mutations before the first insert keep it
new at version 1. Persist it with INSERT, including version 1; do not treat it as an update.
Creation alone does not save it.
For virtual entities that are not owned by a single database, pass optional metadata as a third
argument: User.createEntity(data, state, { id, createdAt, updatedAt }). Every key is optional.
A missing id is generated; a provided id is validated against the id schema (invalid ids
return INVALID_DATA). Missing dates default to now, and updatedAt defaults to createdAt.
Mutating an entity
mutateEntity() is used by domain methods and returns a new immutable entity; the source instance
is unchanged. After a successful mutation:
- A
newentity staysnewat version 1, regardless of how many times it is mutated before insert. - The first mutation of an
unchangedentity marks the resultupdatedand increments the version once. - Further mutations while it remains
updatedkeep that version. This lets several runtime changes form one edit cycle and one database update. - A schema validation failure returns
INVALID_DATA; it does not produce a mutated entity.
For example, if the loaded entity is version 4, its first mutation produces version 5. More mutations in that cycle remain version 5. For an optimistic update, use the previous version as the compare condition and the entity's version as the value to write:
UPDATE users
SET name = :name, version = :newVersion
WHERE id = :id AND version = :expectedVersion;
For an entity at version 5, set newVersion = 5 and expectedVersion = 4 (equivalently,
newVersion = expectedVersion + 1).
Treat an update that affects zero rows as a concurrency conflict: another writer changed or
removed the row. Do not mark the entity clean or retry by silently replacing its version; reload
the latest snapshot and resolve the conflict according to the application's rules. An entity with
persistenceStatus: 'unchanged' can be skipped by the repository.
Describing persistence changes
toPersistenceChanges() returns a database-agnostic descriptor containing the current metadata and
the data a repository may choose to write. It does not execute a query or require a repository to
use a particular persistence strategy:
new:setcontains all current entity data, suitable as the values for an insert.updated:setcontains touched fields with their latest values;unsetcontains fields removed from the current data, for example when a state transition removes a state-specific field.unchanged: bothsetandunsetare empty.
Touched fields accumulate since restoreEntity(). If a field changes and is later reverted, it
remains in set with its latest value. Repositories can use this descriptor to avoid writing
unmodified fields, or ignore it and persist a complete snapshot instead. The library does not map
entity fields to database columns or decide how a database represents an unset field. Changes are
reported by top-level entity field; a nested object is supplied as the value of its top-level
field. Like snapshots, the method accepts { target: 'json' } to serialize dates and bigints for
JSON-based storage.
For optimistic concurrency, a repository can include meta.version in its update condition and
write that version together with the changes. For example, when the descriptor has version 5, an
update can require the stored version to be 4 and set it to 5. This prevents overwriting a row
changed by another writer, provided every writer follows the same versioning rule. A zero-row
update is a conflict, not a successful save.
Restoring after persistence
User.restoreEntity(snapshot) is static and reconstructs an entity from a persisted snapshot. It
validates the id, positive safe-integer version, and data against the schema selected by
meta.metaStatus. The metaStatus field may be omitted when the database query does not return it;
in that case, the data is checked against state schemas in declaration order and the first matching
state is inferred. If state schemas overlap, this order determines the inferred state, so use
distinctive required/discriminant fields when omitting the status. When metaStatus is provided,
the result keeps that specific state type; when omitted, it has the union of possible states. A
successful result has persistenceStatus: 'unchanged' and preserves the snapshot's version;
restoration itself neither saves nor increments that version.
Call it only after the insert/update succeeds, preferably with the row returned by the database. That restored instance is the baseline for the next edit cycle. After saving version 5, restoring the stored row yields an unchanged version-5 entity; its next successful mutation begins version 6. If the write failed due to a version conflict, keep the conflict outcome rather than restoring the attempted snapshot as though it had been persisted.
The restore input is intentionally not a discriminated union: data uses a merged shape across
all states. Fields required in every state are required at compile time; state-specific fields are
optional so ordinary database result types remain usable. Runtime validation checks the complete
schema for the explicit meta.metaStatus, or infers a state and validates against that state's
schema when the status is omitted. In either case, the selected state's required fields must be
present. The metaStatus type is restricted to the entity's declared states; invalid runtime input
returns INVALID_DATA rather than throwing.
The persistence status is not serialized: it is determined by the operation (createEntity makes
new; restoreEntity makes unchanged).
Snapshots include meta.version, but not persistenceStatus. toSnapshot() preserves the
state-specific data shape and is assignable to EntityFlatSnapshot, the merged shape that
restoreEntity() accepts. JSON-target snapshots convert dates and bigints to strings; convert those values back to the configured native
types before passing a snapshot to restoreEntity(). Supporting an explicit native-versus-JSON
restore mode, with decoding of dates, Temporal values, and bigints, remains a planned improvement.
The schema callbacks above adapt Zod object schemas to the factory. If your states use different schemas or you use a different validation library, provide equivalent callbacks for its Standard Schema-compatible schemas.
Publishing
The package is configured for public npm publication. Releases after the initial version are published by GitHub Actions when a v* tag matching the version in package.json is pushed. The workflow runs type checks, lint, tests, build, and publint before publishing with npm provenance.
One-time setup
-
Confirm that your npm account owns the
@caverarscope and that@caverar/ts-entityis available. -
For the first release, publish version
1.0.0from this checkout. npm needs the package to exist before you can configure its trusted publisher:bun install --frozen-lockfile npm login npm publish --access public -
On npmjs.com, open
@caverar/ts-entity→ Settings → Trusted Publisher, choose GitHub Actions, and set:- Owner:
caverar - Repository:
ts-entity - Workflow:
publish.yml - Environment: leave blank
- Owner:
-
Push a matching tag for the next unpublished version (for example, after updating the package to
1.0.1, pushv1.0.1). Do not push av1.0.0tag after publishing1.0.0manually; npm does not allow publishing the same version twice.
No npm token or GitHub secret is required for releases after trusted publishing is enabled. npm validates a new trusted publisher configuration on its first successful publish, so push a release tag within two days of creating the configuration.