Models and variant schemas
A single domain object often needs several different schema shapes. The row you
read from the database is not the row you insert (the database generates the id
and timestamps), and neither matches the JSON you accept on an HTTP POST or the
JSON you serialize on a response (you must never leak a password hash). Writing
those shapes by hand means keeping four or five near-identical structs in sync.
Model solves this by keeping one field declaration as the source of truth and
deriving the variants from it: select, insert, update (database-facing) plus
json, jsonCreate, jsonUpdate (API-facing). Each variant is a real
Schema.Struct, so you decode, encode, and compose
with it like any other schema. VariantSchema is the lower-level engine that
powers Model — use it directly when you want a different set of variants (for
example public / private).
import { Model } from "effect/unstable/schema"The common case: Model.Class
Section titled “The common case: Model.Class”Define a class once. Plain schemas (like Schema.String) appear in every variant;
the Model.* field helpers opt a property into only the variants where it makes
sense.
import { Schema } from "effect"import { Model } from "effect/unstable/schema"
// Branded id so a GroupId can't be confused with another numeric id.export const GroupId = Schema.Number.pipe(Schema.brand("GroupId"))
export class Group extends Model.Class<Group>("Group")({ // Database generates the id: present in select/json, omitted from insert/update. id: Model.GeneratedByDb(GroupId), // Plain schema -> present in ALL variants. name: Schema.String, // Stamped with the current time on insert; omitted from update. createdAt: Model.DateTimeInsertFromDate, // Stamped with the current time on insert AND update. updatedAt: Model.DateTimeUpdateFromDate}) {}
// The class itself is the `select` schema (the full row):GroupGroup.fields // the original field declaration
// The generated variants, each a Schema.Struct in its own right:Group.insert // { name, createdAt, updatedAt } — no id, timestamps auto-filledGroup.update // { name, updatedAt } — no id/createdAt, updatedAt auto-filledGroup.json // { id, name, createdAt, updatedAt } as JSONGroup.jsonCreate // { name }Group.jsonUpdate // { name }Because every variant is a normal schema, you decode/encode at the boundary it matches:
import { Schema } from "effect"
// Validate an incoming HTTP POST body against the create variant.const decodeCreate = Schema.decodeUnknownSync(Group.jsonCreate)decodeCreate({ name: "Engineers" })// => { name: "Engineers" }
// Encode a full row to JSON for a response (DateTime -> ISO string).const toJson = Schema.encodeUnknownSync(Group.json)You can wrap any variant in its own Schema.Class to attach
methods or getters:
import { Schema } from "effect"
class GroupJson extends Schema.Class<GroupJson>("GroupJson")(Group.json) { get upperName() { return this.name.toUpperCase() }}This page documents the schema mechanics of Model and VariantSchema. For
turning a model into a CRUD repository with insert / findById / update and
running migrations, see SQL models and migrations.
How variants map to your boundaries
Section titled “How variants map to your boundaries”| Boundary | Variant | Why |
|---|---|---|
| Read a DB row | select (the class itself) | full persisted shape |
| Write a new DB row | insert | drops generated columns, fills insert defaults |
| Patch a DB row | update | fills update timestamps |
| Serialize a response | json | hides sensitive fields, encodes dates as strings |
| Validate a create request | jsonCreate | drops server-controlled fields |
| Validate an update request | jsonUpdate | drops server-controlled fields |
Model reference
Section titled “Model reference”Constructors and variant builders
Section titled “Constructors and variant builders”These are re-exported from VariantSchema pre-configured with the six
database/JSON variants, so you rarely call VariantSchema.make yourself.
Model.Class
Section titled “Model.Class”Defines a model as a schema class. The class is the default (select) schema and
exposes the other variants as static properties.
import { Schema } from "effect"import { Model } from "effect/unstable/schema"
class User extends Model.Class<User>("User")({ id: Model.GeneratedByDb(Schema.Number), name: Schema.String}) {}
Schema.decodeUnknownSync(User)({ id: 1, name: "Ada" })// => User { id: 1, name: "Ada" }Model.Struct
Section titled “Model.Struct”Like Model.Class but produces a plain variant struct (no class identity). Useful
for nesting inside another model or for extracting variants manually.
import { Schema } from "effect"import { Model } from "effect/unstable/schema"
const Address = Model.Struct({ street: Schema.String, city: Schema.String})Model.extract(Address, "json") // -> Schema.Struct<{ street; city }>Model.Field
Section titled “Model.Field”Builds a variant field from a map of variant -> schema. A property only appears
in the variants you list. This is the primitive all the helpers below are built on.
import { Schema } from "effect"import { Model } from "effect/unstable/schema"
// Appears in select/update/json, but not insert/jsonCreate/jsonUpdate.const slug = Model.Field({ select: Schema.String, update: Schema.String, json: Schema.String})Model.FieldOnly
Section titled “Model.FieldOnly”Pipeable helper that puts one schema into only the named variants.
import { Schema } from "effect"import { Model } from "effect/unstable/schema"
// Only readable, never written.const computed = Schema.String.pipe(Model.FieldOnly(["select", "json"]))Model.FieldExcept
Section titled “Model.FieldExcept”The inverse: applies a schema to every variant except the named ones.
import { Schema } from "effect"import { Model } from "effect/unstable/schema"
// Present everywhere except insert (DB assigns it, but it stays updatable).const id = Schema.Number.pipe(Model.FieldExcept(["insert"]))Model.fieldEvolve
Section titled “Model.fieldEvolve”Transforms the per-variant schemas of an existing field (or lifts a plain schema into a field) by variant name.
import { Schema } from "effect"import { Model } from "effect/unstable/schema"
// Make the json variants nullable while leaving DB variants untouched.const note = Schema.String.pipe( Model.fieldEvolve({ json: Schema.NullOr, jsonCreate: Schema.NullOr, jsonUpdate: Schema.NullOr }))Model.extract
Section titled “Model.extract”Pulls a single variant schema out of a model or variant struct as a
Schema.Struct.
import { Schema } from "effect"import { Model } from "effect/unstable/schema"
class Group extends Model.Class<Group>("Group")({ id: Model.GeneratedByDb(Schema.Number), name: Schema.String}) {}
Model.extract(Group, "insert") // -> Schema.Struct<{ name: Schema.String }>Model.fields
Section titled “Model.fields”Returns the original field declaration object stored on a model/struct (the same
value you passed to Model.Class).
Group.fields// => { id: GeneratedByDb<...>, name: Schema.String }Model.Union
Section titled “Model.Union”Builds a union over the default schemas of several variant structs, and exposes a per-variant union on each variant name. Add your own literal tag if you need runtime discrimination.
import { Schema } from "effect"import { Model } from "effect/unstable/schema"
const A = Model.Struct({ _tag: Schema.Literal("A"), a: Schema.String })const B = Model.Struct({ _tag: Schema.Literal("B"), b: Schema.Number })
const AB = Model.Union([A, B])AB.insert // union of A.insert | B.insertModel.Override
Section titled “Model.Override”Brands a value so it is used in place of an Overrideable default (such as the
auto-now() of the timestamp helpers). Pass it to a constructor when you want to
supply the value explicitly.
import { DateTime } from "effect"import { Model } from "effect/unstable/schema"
// Instead of letting createdAt default to the current time:Model.Override(DateTime.makeUnsafe("2024-01-01T00:00:00Z"))Generated and lifecycle fields
Section titled “Generated and lifecycle fields”Model.GeneratedByDb
Section titled “Model.GeneratedByDb”A column the database generates (auto-increment id, computed column). Present
in select and json (read-only); omitted from insert and update.
import { Schema } from "effect"import { Model } from "effect/unstable/schema"
const id = Model.GeneratedByDb(Schema.Number)// insert variant: id is absent; select variant: id is requiredModel.GeneratedByApp
Section titled “Model.GeneratedByApp”A value the application generates before insert (e.g. a client-side UUID).
Present in select, insert, update, and json; omitted from jsonCreate and
jsonUpdate (the API client never sets it).
import { Schema } from "effect"import { Model } from "effect/unstable/schema"
const ref = Model.GeneratedByApp(Schema.String)Model.Sensitive
Section titled “Model.Sensitive”A value present in all database variants but stripped from every JSON variant — ideal for password hashes and secrets that must never be serialized.
import { Schema } from "effect"import { Model } from "effect/unstable/schema"
const passwordHash = Model.Sensitive(Schema.String)// Present in select/insert/update; absent from json/jsonCreate/jsonUpdate.Model.optionalOption
Section titled “Model.optionalOption”A schema (not a field) for a nullable DB column: the encoded side is optional &
nullable, the decoded side is an Option.
import { Schema } from "effect"import { Model } from "effect/unstable/schema"
const bio = Model.optionalOption(Schema.String)Schema.decodeUnknownSync(Schema.Struct({ bio }))({ bio: null })// => { bio: Option.none() }Schema.decodeUnknownSync(Schema.Struct({ bio }))({ bio: "hi" })// => { bio: Option.some("hi") }Model.FieldOption
Section titled “Model.FieldOption”Converts a field (or plain schema) so it is optional in every variant: nullable for
the database variants, optional-and-nullable (decoded as Option) for the JSON
variants.
import { Schema } from "effect"import { Model } from "effect/unstable/schema"
const nickname = Schema.String.pipe(Model.FieldOption)// DB variants: Option from null; JSON variants: Option from missing/null key.Encoding helpers
Section titled “Encoding helpers”Model.BooleanSqlite
Section titled “Model.BooleanSqlite”SQLite has no boolean type. This field stores 0 | 1 in the database variants and
exposes a real boolean in the JSON variants.
import { Schema } from "effect"import { Model } from "effect/unstable/schema"
class Flag extends Model.Class<Flag>("Flag")({ active: Model.BooleanSqlite}) {}
Schema.decodeUnknownSync(Flag)({ active: 1 }) // => Flag { active: true }Schema.decodeUnknownSync(Flag.json)({ active: true }) // => { active: true }Model.Date
Section titled “Model.Date”A DateTime.Utc serialized as a YYYY-MM-DD string (the time component is
removed on decode).
import { Schema } from "effect"import { Model } from "effect/unstable/schema"
const decoded = Schema.decodeUnknownSync(Model.Date)("2024-03-14")Schema.encodeUnknownSync(Model.Date)(decoded) // => "2024-03-14"Model.JsonFromString
Section titled “Model.JsonFromString”A field whose value is stored as a JSON string in the database variants but uses the structured schema directly in the JSON variants.
import { Schema } from "effect"import { Model } from "effect/unstable/schema"
const Settings = Schema.Struct({ theme: Schema.String })const settings = Model.JsonFromString(Settings)
class Prefs extends Model.Class<Prefs>("Prefs")({ settings }) {}
// DB variant decodes from a string column:Schema.decodeUnknownSync(Prefs)({ settings: `{"theme":"dark"}` })// => Prefs { settings: { theme: "dark" } }// JSON variant uses the object directly:Schema.decodeUnknownSync(Prefs.json)({ settings: { theme: "dark" } })// => { settings: { theme: "dark" } }Model.Uint8Array
Section titled “Model.Uint8Array”The Uint8Array schema (backed by an ArrayBuffer) used by the binary UUID
helpers; handy for BLOB columns.
import { Model } from "effect/unstable/schema"
Model.Uint8Array // Schema.instanceOf<Uint8Array<ArrayBuffer>>DateTime audit-column helpers
Section titled “DateTime audit-column helpers”These fields fill audit timestamps automatically with the current time. The
*Insert* variants set the value only on insert; the *Update* variants set it on
both insert and update. The suffix controls the database encoding — string,
JavaScript Date, or epoch milliseconds (number) — while JSON always uses an ISO
string. In every case select and json are readable.
import { Model } from "effect/unstable/schema"
class Doc extends Model.Class<Doc>("Doc")({ // Set once on insert; omitted from update. createdAt: Model.DateTimeInsertFromDate, // DB: Date column // Refreshed on insert AND update. updatedAt: Model.DateTimeUpdateFromDate // DB: Date column}) {}Insert-only helpers (omitted from update):
Model.DateTimeInsert— DB encodes as a string.Model.DateTimeInsertFromDate— DB encodes as a JavaScriptDate.Model.DateTimeInsertFromNumber— DB encodes as epoch milliseconds.
Insert-and-update helpers (refreshed on every write):
Model.DateTimeUpdate— DB encodes as a string.Model.DateTimeUpdateFromDate— DB encodes as a JavaScriptDate.Model.DateTimeUpdateFromNumber— DB encodes as epoch milliseconds.
Each is built on an Overrideable default, so you can supply the value explicitly
with Model.Override instead of taking the auto-now():
import { DateTime } from "effect"import { Model } from "effect/unstable/schema"
class Doc extends Model.Class<Doc>("Doc")({ name: Schema.String, createdAt: Model.DateTimeInsertFromDate}) {}
// Auto: createdAt defaults to the current time on insert.Doc.insert.make({ name: "spec" })// Explicit: pin createdAt instead of using now().Doc.insert.make({ name: "spec", createdAt: Model.Override(DateTime.makeUnsafe("2024-01-01T00:00:00Z"))})Overrideable default schemas
Section titled “Overrideable default schemas”These are the underlying Overrideable schemas the audit fields are made of. Use
them directly when building a custom field whose constructor should default to the
current time.
Model.DateWithNow— date-only (YYYY-MM-DD), defaults to today.Model.DateTimeWithNow— string-encoded, defaults to now.Model.DateTimeFromDateWithNow—Date-encoded, defaults to now.Model.DateTimeFromNumberWithNow— millis-encoded, defaults to now.
import { Model } from "effect/unstable/schema"
class Snapshot extends Model.Class<Snapshot>("Snapshot")({ // Available in all variants; defaults to now in its constructor. takenAt: Model.DateTimeWithNow}) {}UUID helpers
Section titled “UUID helpers”Each *Insert helper is a field whose insert variant carries a constructor
default that generates a UUID, while select / update / json keep the value as
a (branded) read-only column. The *WithGenerate functions are the underlying
schemas, so you can attach the generating default to a field of your own.
import { Schema } from "effect"import { Model } from "effect/unstable/schema"
const UserId = Schema.String.pipe(Schema.brand("UserId"))
class User extends Model.Class<User>("User")({ id: Model.UuidV7Insert(UserId), name: Schema.String}) {}
// On insert the id is optional — it is generated if you omit it:User.insert.make({ name: "Ada" })Model.UuidV4Insert(brandedString)/Model.UuidV4WithGenerate(brandedString)— string UUID v4.Model.UuidV7Insert(brandedString)/Model.UuidV7WithGenerate(brandedString)— string UUID v7 (time-ordered).Model.UuidV4BytesInsert(brandedBytes)/Model.UuidV4BytesWithGenerate(brandedBytes)— binary (Uint8Array) UUID v4 for compactBLOBstorage.
import { Schema } from "effect"import { Model } from "effect/unstable/schema"
// Branded byte id for a compact binary primary key.const TokenId = Model.Uint8Array.pipe(Schema.brand("TokenId"))const id = Model.UuidV4BytesInsert(TokenId)VariantSchema reference
Section titled “VariantSchema reference”VariantSchema is the engine behind Model. Call VariantSchema.make with your
own variant names to get a toolkit specialized to them. Reach for it when the
six database/JSON variants don’t fit — for example a public / private API
split.
import { VariantSchema } from "effect/unstable/schema"VariantSchema.make
Section titled “VariantSchema.make”Fixes a closed set of variant names and a default variant, returning a toolkit of
Struct, Field, FieldOnly, FieldExcept, fieldEvolve, Class, Union, and
extract — all typed to your variants.
import { Schema } from "effect"import { VariantSchema } from "effect/unstable/schema"
const { Class, Field, extract } = VariantSchema.make({ variants: ["public", "private"], defaultVariant: "public"})
class Account extends Class<Account>("Account")({ id: Schema.String, // plain schema -> present in BOTH variants // Field opts a property into specific variants: email: Field({ private: Schema.String }), // only the private view displayName: Field({ public: Schema.String, private: Schema.String })}) {}
Account // the default (public) schema: { id, displayName }Account.private // { id, email, displayName }extract(Account, "private") // -> Schema.Struct of the private variantThe toolkit members mirror their Model counterparts documented above
(Class, Struct, Field, FieldOnly, FieldExcept, fieldEvolve, Union,
extract), but typed to your variant names rather than the
select/insert/update/json set.
Types and guards
Section titled “Types and guards”VariantSchema.TypeId
Section titled “VariantSchema.TypeId”The runtime type identifier attached to every variant struct ("~effect/schema/VariantSchema").
import { VariantSchema } from "effect/unstable/schema"
VariantSchema.TypeId // => "~effect/schema/VariantSchema"VariantSchema.isStruct
Section titled “VariantSchema.isStruct”Type guard for a variant schema struct.
import { Schema } from "effect"import { VariantSchema } from "effect/unstable/schema"
const { Struct } = VariantSchema.make({ variants: ["a", "b"], defaultVariant: "a"})
VariantSchema.isStruct(Struct({ x: Schema.String })) // => trueVariantSchema.isStruct(Schema.String) // => falseVariantSchema.isField
Section titled “VariantSchema.isField”Type guard for a variant field (the value Field({...}) returns).
import { Schema } from "effect"import { VariantSchema } from "effect/unstable/schema"
const { Field } = VariantSchema.make({ variants: ["a", "b"], defaultVariant: "a"})
VariantSchema.isField(Field({ a: Schema.String })) // => trueVariantSchema.fields
Section titled “VariantSchema.fields”Returns the original field declarations stored on a variant struct (same value you passed in).
import { Schema } from "effect"import { VariantSchema } from "effect/unstable/schema"
const { Struct } = VariantSchema.make({ variants: ["a", "b"], defaultVariant: "a"})
VariantSchema.fields(Struct({ x: Schema.String }))// => { x: Schema.String }VariantSchema.Override
Section titled “VariantSchema.Override”The lower-level form of Model.Override: brands a value to bypass an
Overrideable default.
import { VariantSchema } from "effect/unstable/schema"
VariantSchema.Override("fixed-value")VariantSchema.Overrideable
Section titled “VariantSchema.Overrideable”Wraps a schema with an effectful constructor default. The constructor uses the
default unless the supplied value is branded with Override. This is exactly how
the Model.DateTime*WithNow helpers are built.
import { DateTime, Effect, Schema } from "effect"import { VariantSchema } from "effect/unstable/schema"
// A field that defaults to the current time but can be overridden explicitly.const createdAt = VariantSchema.Overrideable(Schema.DateTimeUtcFromString, { defaultValue: DateTime.now})Type-level exports
Section titled “Type-level exports”For library authors writing typed wrappers, the module also exports the interfaces/namespaces describing these shapes:
VariantSchema.Struct<A>— a variant struct over a field map.VariantSchema.Field<A>— a variant field over avariant -> schemaconfig.VariantSchema.Class— the schema-class type returned by a toolkit’sClass.VariantSchema.Union— the union type returned by a toolkit’sUnion.
Related pages
Section titled “Related pages”- SQL models and migrations — turn a
Modelinto a repository withSqlModel.makeRepositoryand evolve the schema with the Migrator. - Schema classes — wrap a variant in a
Schema.Classto add methods and identity. - Structs and records — the struct fundamentals every variant is built from.