Skip to content

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) uses optionalKey: the key is present or absent, nothing else.
  • optional is for values produced in JavaScript that can genuinely hold undefined: search params, tool arguments, optional function options.
  • The rule depends on exactOptionalPropertyTypes: true in the root tsconfig (see TypeScript config). Without it both produce age?: 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.Type

Source: 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. makeEntityId is for ids we mint (UUIDv7 via newId) and checks Schema.isUUID(). makeExternalId is for ids someone else mints: trimmed and non-empty, never our primary key.
  • No other Schema.brand call creates an id. See ids and identity.
  • Since 4.0.0 Schema.brand takes a single concrete identifier, so a generic factory spells its parameter as Parameters<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:

packages/domain/src/ids.ts
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.Type

Source: notes/07-schema-and-data/schema-modeling.md · Decision 3, amended

Impact: MEDIUM avoids a rewrite when an unstable API moves

  • effect/schema/Model gives 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 Row schema decoded by SqlSchema, plus explicit fromRow / toRow mappers (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 left
const 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

  • effect/schema/Model — trigger: Model is tagged @stability stable, or a hand-kept second shape causes a bug.