Repository navigation
Nullable
.nullable() makes a Schema accept null as well. It is a Shared Modifier, so every Schema has it.
const deletedAt = pvl.string().nullable();
deletedAt.validate('2026-01-01'); // { value: '2026-01-01' }
deletedAt.validate(null); // { value: null }
deletedAt.validate(undefined); // { issues: [{ code: 'INVALID_TYPE', message: 'Expected string' }] }Unlike .optional(), an object key stays required: null must be passed explicitly. Chain both to accept either.
null is accepted on the spot and skips every check after it. Any other value goes on to be checked as usual.
flowchart LR
V["value"] --> Q{"value === null?"}
Q -- yes --> OK["accepted as it is"]
Q -- no --> REST["the type check and every other step"]
.nullable() works exactly as .optional() does, testing for null: it appends a pre-modifier tagged SHORT_CIRCUIT, whose { value: null } skips the rest of the pre-modifiers, the type check and every post-modifier except a .transform().
flowchart TD
CALL[".nullable()"] --> ADD["_withPreModifier({ tags: [SHORT_CIRCUIT], fn })"]
ADD --> RUN["_runPreModifiers reaches it, in chain order"]
RUN --> FN{"fn(value): value === null?"}
FN -- "no → null" --> NEXT["the next pre-modifier,<br/>then _checkType, then post-modifiers"]
FN -- "yes → { value: null }" --> SC["SHORT_CIRCUIT: stop the pre-modifiers,<br/>skip _checkType"]
SC --> POST["_runPostModifiers skips every step<br/>but a RUNS_AFTER_SHORT_CIRCUIT one (.transform())"]
POST --> RES["{ value: null }"]
ChainableSchemaEmitter.shortCircuitConditions.nullable writes the condition value === null, and the Frame wraps every later check in if (!(…)).
flowchart TD
CALL[".nullable() in schema.preModifierCalls"] --> TAB["_emitPreModifierSteps finds 'nullable'<br/>in shortCircuitConditions"]
TAB --> COND["condition text: value === null"]
COND --> GROUP["Frame.groupPreModifierSteps<br/>joins it with any .optional() next to it"]
GROUP --> WRAP["Frame.composeChecks:<br/>if (!((value === null))) { every later check }"]
pvl.compile(pvl.string().nullable()) compiles to:
_validate(value: unknown, path: ReadonlyArray<PropertyKey>): PvlResult<unknown> {
const issues: PvlIssue[] = [];
if (!((value === null))) {
if (typeof value !== "string") {
issues.push({ code: "INVALID_TYPE", message: "Expected string", path: path.length > 0 ? path : undefined });
}
}
return issues.length > 0 ? { issues } : { value };
}pvl.string().nullable().optional() shares one guard: if (!((value === null) || (value === undefined))).
pvl.string().nullable().coerce() keeps null, while pvl.string().coerce().nullable() turns it into 'null' first. See Coerce.
-
chainableInterpretedSchema.ts:nullable() -
emitters/chainableSchemaEmitter.ts:nullable, inshortCircuitConditions -
emitters/frame.ts:groupPreModifierSteps,composeChecks
Further reading: pipeline.md, ADR-0010.