Nullable vs optional
.optional() and .nullable() look alike but model different ideas: optional is about whether the value is there, nullable is about whether it can be null. Sapphire keeps the two separate at every layer — brand types, IR, and adapter output — because storage frameworks differentiate “no field at all” from “field present but null”. Conflating them is the most common source of “why doesn’t my schema match” confusion; this page is short on purpose.
The table
| Modifier | _output | Semantic |
|---|---|---|
| (none) | T | required, value present |
.optional() | T | undefined | field may be absent |
.nullable() | T | null | value present but may be null |
.optional().nullable() | T | null | undefined | both |
Why distinct?
Storage and serialization frameworks model “absent” and “null” differently. Mongoose treats required: false (absence allowed) and default: null (present-with-null) as separate concerns. JSON Schema distinguishes a key missing from required[] (absence) from a type: ['x', 'null'] value (present null). Drizzle distinguishes a nullable column from a column simply not part of an insert. If Sapphire collapsed them, you’d lose information on the way out to the adapter.
Runtime behavior
const nullableName = a.string().nullable()
nullableName.parse(null) // → null
const optionalName = a.string().optional()
optionalName.parse(undefined) // → undefined
null passed to a non-nullable required field fails with code: 'required' — Sapphire treats null like a missing value unless the field is .nullable(). If you want strict typing where null is “present but bad” instead of “missing”, use .nullable() on the schema and reject null in your application layer.
Inside an object
const userOpt = a.object({ nickname: a.string().optional() })
type UserOpt = Infer<typeof userOpt>
// UserOpt = { nickname?: string | undefined } // the KEY is optional
const userNul = a.object({ nickname: a.string().nullable() })
type UserNul = Infer<typeof userNul>
// UserNul = { nickname: string | null } // the key is REQUIRED
Only .optional() lifts the property into a ?: position. .nullable() keeps the key required and widens the value type to T | null.
Adapter behavior
- mongo —
.optional()→required: false..nullable()is rare in Mongoose; the value is allowed but storage still requires the key to be present. - json-schema —
.optional()removes the key fromrequired[]..nullable()widens the type to['x', 'null'](or wraps inoneOfwhen there are constraints incompatible with a type-array). - drizzle —
.optional()and.nullable()both omitnotNull()(the column accepts NULL). The semantic difference is on the input side; the column itself is the same.
Pitfalls
[!WARNING]
.optional().default('x')keeps_outputasT | undefined. Once.optional()widens the output brand toT | undefined,.default()doesn’t narrow it back — only the input side. If you want_output: T, drop.optional()and rely on.default('x')alone (it already widens_inputtoT | undefinedso callers may omit the key).
[!WARNING]
nullis treated as “missing” by non-nullable fields. CallingsafeParse(null)on a non-nullable required field fails withcode: 'required'(NOT'invalid_type') — Sapphire short-circuits null/undefined together before the type check. Form libraries that sendnullfor empty fields land in the samerequiredbucket asundefined. To get a real “present but null is illegal” semantic, mark the field.nullable()and validate further in your code.
Related
- Inferring types — full
_input/_outputtable including.default(). - Validation —
IssueCodesemantics forrequiredvsinvalid_type. - Fields and modifiers — universal modifier reference.