Fields and modifiers

Sapphire’s field DSL is the user-facing surface of the library. Every call on a Sapphire instance (a.string(), a.object({...}), a.type().union([...])) returns a new, immutable Field. Modifiers like .optional(), .min(3), .default('x') return new fields rather than mutating the previous one — chains are safe to share and reuse.

This page is the reference for every field constructor and every modifier it supports. For composition methods on objects (pick, omit, partial, required, extend, merge) see Composition. For union/literal/enum see Unions, literals, and enums.

Minimal example

const user = a.object({
  name: a.string().min(1).max(120),
  age: a.number().int().nonnegative(),
  active: a.boolean().default(true),
  createdAt: a.date(),
})

type User = Infer<typeof user>
// User = { name: string; age: number; active: boolean; createdAt: Date }

Primitives

a.string()

ModifierSignatureNotes
.min(n, opts?)(n: number, opts?: { message? }) => StringFieldMinimum length. Error code min_length.
.max(n, opts?)sameMaximum length. Error code max_length.
.length(n, opts?)sameExact length. Error code length.
.regex(re, opts?)(re: RegExp, opts?: { message? }) => StringFieldTests with re.test(str). Error code regex.
.email(opts?)(opts?: { message? }) => StringFieldBuilt-in format check (see note). Error code format.
.url(opts?)sameFormat url — uses the platform URL constructor; accepts anything new URL(value) parses.
.uuid(opts?)sameFormat uuid — RFC 4122 v1–v8 with valid variant nibble (8/9/a/b).
.startsWith(prefix, opts?)(prefix: string, opts?) => StringFieldError code starts_with.
.endsWith(suffix, opts?)(suffix: string, opts?) => StringFieldError code ends_with.
.trim()() => StringFieldTransform — runs after coerce, before rule checks.
.toLowerCase()() => StringFieldTransform.
.toUpperCase()() => StringFieldTransform.
.coerce()() => StringFieldString(value) if input is not a string (skipped for null/undefined — those go through the required/nullable check). Runs first (see pitfall).

Example:

const email = a.string().min(3).max(254).email()

[!NOTE] .email() is pragmatic, not RFC 5322 complete. The built-in regex covers ~99% of practical addresses — it rejects obvious junk (a@b..c, leading/trailing dots, missing TLD) but does not accept exotic-but-RFC-legal forms like quoted local parts ("foo bar"@example.com). If you need full RFC compliance, plug a third-party validator via .regex(yourRegex) or post-safeParse checks.

a.number()

ModifierSignatureNotes
.min(n, opts?)(n: number, opts?: { message? }) => NumberFieldInclusive lower bound. Error code min.
.max(n, opts?)sameInclusive upper bound. Error code max.
.gt(n, opts?)sameExclusive lower bound. Error code gt.
.gte(n, opts?)sameInclusive lower; reported as code gte.
.lt(n, opts?)sameExclusive upper. Error code lt.
.lte(n, opts?)sameInclusive upper; reported as code lte.
.int(opts?)(opts?) => NumberFieldNumber.isInteger. Error code int.
.positive(opts?)sugar for .gt(0)
.negative(opts?)sugar for .lt(0)
.nonnegative(opts?)sugar for .gte(0)
.nonpositive(opts?)sugar for .lte(0)
.multipleOf(n, opts?)(n: number, opts?) => NumberFieldvalue % n === 0. Error code multiple_of.
.finite(opts?)(opts?) => NumberFieldRejects Infinity / -Infinity. Error code finite.
.safe(opts?)(opts?) => NumberFieldNumber.isSafeInteger. Error code safe.
.coerce()() => NumberFieldNumber(value); preserves the original if NaN.

Example:

const age = a.number().int().gte(0).lte(150)

a.boolean()

Boolean has no rule modifiers — just universal ones and .coerce(). With coerce, the strings 'true'/'false' and numbers 1/0 are accepted.

a.date()

ModifierSignatureNotes
.min(d, opts?)(d: Date, opts?) => DateFieldvalue.getTime() >= d.getTime(). Error code min.
.max(d, opts?)sameError code max.
.coerce()() => DateFieldnew Date(value) for number/string before validation.

Strict by default — only Date instances are accepted. Call .coerce() to also accept strings or numbers (form payloads, URL params, JSON-deserialized timestamps); the adapter IR (coerce: false vs coerce: true) honestly reflects what runtime will accept.

Composites

a.object(shape)

Shape is a record of Fields. The output is a plain object whose keys mirror shape, with optional fields lifted into ?: positions.

ObjectField carries schema-level metadata too: .name(n), .timestamps(), .index(keys, opts?). It also exposes the composition methods pick, omit, partial, required, extend, merge — those have their own page in Composition.

a.array(item)

Homogeneous, variable-length. The single item argument is the inner field — every entry is validated against it. Modifiers: .min(n), .max(n), .length(n), .nonempty().

a.tuple([f1, f2, ...])

Heterogeneous, fixed-length. See Tuples vs arrays for the comparison.

Type namespace

a.type() returns a builder for the non-leaf kinds:

CallReturnsNotes
a.type().union([f1, f2, ...])UnionField_output = T1 | T2 | .... See unions-literals-enums.
a.type().literal(value)LiteralFieldvalue: string | number | boolean. _output = value (narrowed).
a.type().enum(values | tsEnum)EnumFieldAccepts ['a','b'] as const or a TS enum.
a.type().record(keyField, valueField)RecordFieldPlain object whose keys conform to keyField, values to valueField.

Refs

a.ref(target) declares “this position holds a reference to a named schema”. target is either an ObjectField returned by .name(...) or the name string directly. Validation checks presence and that the referenced name is registered on the Sapphire instance — an unregistered target fails safeParse with a ref_target_missing issue. It does not check the referenced value’s shape in v1. Adapters resolve the target shape (Mongo ObjectId, JSON Schema $ref, Drizzle references(() => target.id)). Detailed coverage in Refs and relations.

Universal modifiers

These are available on every field. (unique, index, and the schema-level name/timestamps are not strictly universal — see the per-field tables — but follow the same chainable convention.)

ModifierSignatureNotes
.optional()() => Field<T | undefined>Removes the required flag. _output and _input both become T | undefined.
.required()() => Field<Exclude<T, undefined>>Inverse of .optional(). Useful inside generic composition.
.nullable()() => Field<T | null>Distinct from optional. See Nullable vs optional.
.default(value)(value: TOut) => Field<TOut, TIn | undefined>When input is undefined, the default fills in. _input widens to T | undefined.
.describe(text)(text: string) => FieldFree-form description; adapters surface it (Mongo meta, JSON Schema description).
.adapter(name, opts)(name: string, opts: unknown) => FieldPer-adapter escape hatch — opts are merged into the adapter’s output for this field.
.message(msg)(msg: string | FieldMessages) => FieldField-level message override (one of five levels in the resolution hierarchy).
.unique()() => FieldMarks the field as unique in the index sense. String/number/date only. For composite uniqueness across multiple keys, use ObjectField.index([keys], { unique: true }).
.index(opts?)(opts?: { unique?: boolean }) => FieldMarks the field as indexed. String/number/boolean/date.

Example:

const bio = a.string().max(280).optional().describe('User bio (markdown)')
const nickname = a.string().nullable()
const role = a.string().default('member')

Pitfalls

[!WARNING] .coerce() runs first. Order is coerce → default substitution → null/undefined handling → invalid_type check → transforms → rule checks. Validators see the coerced value, not the raw input. The snippet below converts 42 to "42" before the min(3) check fires.

const slug = a.string().coerce().toLowerCase().min(3)
// The number 42 becomes the string "42" first, then lowercases (no-op),
// then is checked against min(3) — which fails because "42".length === 2.

[!WARNING] .nullable() is NOT .optional(). nullable accepts null; optional accepts undefined. They are independent — combine them if you want both. See Nullable vs optional.