Schema modeling
Three small choices come up in every schema you write. This page answers which optional,
Struct or Class, and how a branded id is built, plus why Model is not used yet.
Use Schema.optionalKey unless undefined is a real value
Section titled “Use Schema.optionalKey unless undefined is a real value”Impact: MEDIUM the type is never wider than the data
- JSON has no
undefined. Anything decoded from JSON (HTTP payloads, RPC, database JSON columns) usesoptionalKey: the key is present or absent, nothing else. optionalis for values produced in JavaScript that can genuinely holdundefined: search params, tool arguments, optional function options.- The rule depends on
exactOptionalPropertyTypes: truein the root tsconfig (see TypeScript config). Without it both produceage?: number | undefined, and the compiler stops telling them apart.
❌ Incorrect — a JSON payload that admits an undefined JSON cannot produce:
export const CreateLabelingRuleInput = Schema.Struct({ label: Schema.String, description: Schema.optional(Schema.String), // { description?: string | undefined }})✅ Correct — exact-optional for JSON:
export const CreateLabelingRuleInput = Schema.Struct({ label: Schema.String, description: Schema.optionalKey(Schema.String), // { description?: string }})Source: notes/07-schema-and-data/schema-modeling.md · Decision 1, amended
Use Schema.Struct by default; Schema.Class for identity or methods
Section titled “Use Schema.Struct by default; Schema.Class for identity or methods”Impact: LOW most schemas are data and need only a shape
- Struct for payloads, records, and anything that is a shape and nothing more.
- Class when the type wants derived accessors, a nominal identity, or
instanceof. - Errors are classes by construction, through
Schema.TaggedError(see error modeling).
❌ Incorrect — a class for a plain payload:
export class RuleSummary extends Schema.Class<RuleSummary>("RuleSummary")({ id: RuleId, label: Schema.String,}) {}✅ Correct — a struct, with its type:
export const RuleSummary = Schema.Struct({ id: RuleId, label: Schema.String,})export type RuleSummary = typeof RuleSummary.TypeSource: notes/07-schema-and-data/schema-modeling.md · Decision 2
Build branded ids through a shared factory
Section titled “Build branded ids through a shared factory”Impact: HIGH the id constraint lives in one place and cannot drift
- The factories live in
packages/domain/src/ids.ts.makeEntityIdis for ids we mint (UUIDv7 vianewId) and checksSchema.isUUID().makeExternalIdis for ids someone else mints: trimmed and non-empty, never our primary key. - No other
Schema.brandcall creates an id. See ids and identity. - Since 4.0.0
Schema.brandtakes a single concrete identifier, so a generic factory spells its parameter asParameters<typeof Schema.brand<B>>[0]. Call sites still pass a literal.
❌ Incorrect — each id repeats its own constraint:
export const RuleId = Schema.String.pipe(Schema.brand("RuleId"))export const OrgId = Schema.String.check(Schema.isMinLength(1)).pipe(Schema.brand("OrgId"))✅ Correct — one factory per kind of id:
type BrandKey<B extends string> = Parameters<typeof Schema.brand<B>>[0]
export const makeEntityId = <const B extends string>(brand: BrandKey<B>) => Schema.String.check(Schema.isUUID()).pipe(Schema.brand<B>(brand))
export const makeExternalId = <const B extends string>(brand: BrandKey<B>) => Schema.String.check(Schema.isMinLength(1), Schema.isTrimmed()).pipe(Schema.brand<B>(brand))
export const ThreadId = makeEntityId("ThreadId")export type ThreadId = typeof ThreadId.TypeSource: notes/07-schema-and-data/schema-modeling.md · Decision 3, amended
Don’t use Model yet; map rows by hand
Section titled “Don’t use Model yet; map rows by hand”Impact: MEDIUM avoids a rewrite when an unstable API moves
effect/schema/Modelgives six variants from one definition (select,insert,update,json,jsonCreate,jsonUpdate). It is still tagged@stability unstable, which allows breaking changes in a minor release, and only one repo in the lab uses it.- Without it, the gap between a row and a domain shape is closed by hand: a
Rowschema decoded bySqlSchema, plus explicitfromRow/toRowmappers (see schema at boundaries). - Accepted cost: a second, hand-kept shape per table.
❌ Incorrect — an unstable variant model as the domain type:
import * as Model from "effect/schema/Model"
export class LabelingRule extends Model.Class<LabelingRule>("LabelingRule")({ id: Model.GeneratedByDb(RuleId), label: Schema.String,}) {}✅ Correct — a row schema and an explicit mapper:
// exhaustive: destructure every field, assert nothing is leftconst fromRow = ( { id, repository_id, enabled, created_at, org_id: _orgId, // pinned by the query's predicate ...rest }: typeof Row.Type,): LabelingRule => { rest satisfies Exhausted // packages/db/src/row.ts return { id, repositoryId: repository_id, enabled, createdAt: created_at }}Source: notes/07-schema-and-data/schema-modeling.md · Rejected
Deferred
Section titled “Deferred”effect/schema/Model— trigger:Modelis tagged@stability stable, or a hand-kept second shape causes a bug.