Skip to content

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"

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):
Group
Group.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-filled
Group.update // { name, updatedAt } — no id/createdAt, updatedAt auto-filled
Group.json // { id, name, createdAt, updatedAt } as JSON
Group.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.

BoundaryVariantWhy
Read a DB rowselect (the class itself)full persisted shape
Write a new DB rowinsertdrops generated columns, fills insert defaults
Patch a DB rowupdatefills update timestamps
Serialize a responsejsonhides sensitive fields, encodes dates as strings
Validate a create requestjsonCreatedrops server-controlled fields
Validate an update requestjsonUpdatedrops server-controlled fields

These are re-exported from VariantSchema pre-configured with the six database/JSON variants, so you rarely call VariantSchema.make yourself.

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" }

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 }>

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
})

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"]))

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"]))

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
})
)

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 }>

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 }

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.insert

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"))

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 required

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)

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.

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") }

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.

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 }

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"

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" } }

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>>

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 JavaScript Date.
  • 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 JavaScript Date.
  • 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"))
})

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.DateTimeFromDateWithNowDate-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
}) {}

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 compact BLOB storage.
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 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"

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 variant

The 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.

The runtime type identifier attached to every variant struct ("~effect/schema/VariantSchema").

import { VariantSchema } from "effect/unstable/schema"
VariantSchema.TypeId // => "~effect/schema/VariantSchema"

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 })) // => true
VariantSchema.isStruct(Schema.String) // => false

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 })) // => true

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 }

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")

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
})

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 a variant -> schema config.
  • VariantSchema.Class — the schema-class type returned by a toolkit’s Class.
  • VariantSchema.Union — the union type returned by a toolkit’s Union.

  • SQL models and migrations — turn a Model into a repository with SqlModel.makeRepository and evolve the schema with the Migrator.
  • Schema classes — wrap a variant in a Schema.Class to add methods and identity.
  • Structs and records — the struct fundamentals every variant is built from.