Repository navigation
Refine
.refine(predicate, options?) adds your own yes/no check, a Refinement. It is a Shared Modifier, so every Schema has it. This is where a check the library has no built-in for belongs: a regex, a rule across two fields.
const even = pvl.number().refine((value) => value % 2 === 0, { message: 'must be even' });
even.validate(4); // { value: 4 }
even.validate(3); // { issues: [{ code: 'CUSTOM', message: 'must be even' }] }The predicate never changes the value. Returning false reports a CUSTOM Issue, with your message or the default "Invalid type".
When the predicate is a type predicate, written out ((value): value is T => …) or inferred by TypeScript, the Schema's Output narrows to its type (ADR-0051). A predicate returning a plain boolean leaves it as it was, and the Input never narrows:
pvl.string().refine((value) => value === 'ahoj'); // Output 'ahoj', Input string
pvl.string().refine((value) => value === 'a' || value === 'b'); // Output 'a' | 'b'
pvl.string().refine((value) => value.length > 3); // Output stringA null or undefined that .nullable() or .optional() chained before lets past the predicate stays in the Output, since it never reaches the predicate: pvl.string().nullable().refine((value) => value !== null) outputs string | null. So does one the Schema's own type admits, even when the predicate rejects it: pvl.union([pvl.string(), pvl.number().nullable()]).refine((value) => value !== null) outputs string | number | null (ADR-0051). One chained after adds its own, as always: .refine(isAhoj).optional() outputs 'ahoj' | undefined. The Schema keeps its class, so later Constraints keep the narrowed Output, and a later .refine() or .transform() receives it.
flowchart LR
V["value"] --> TC["type check"]
TC -- passed --> P{"predicate(value)?"}
P -- true --> OK["no Issue"]
P -- false --> I["a CUSTOM Issue"]
It runs after the type check, in chain order among the Constraints: a failing .min() before it doesn't stop it, and both Issues are reported. A value .optional() or .nullable() accepted never reaches it.
.refine() appends an untagged post-modifier. Its function calls your predicate and returns null when it holds, or { issues: [new Issue(CUSTOM, path, message)] }.
flowchart TD
CALL[".refine(predicate, options)"] --> ADD["_withPostModifier({ fn })<br/>no tag"]
ADD --> RUN["_runPostModifiers reaches it, in chain order,<br/>once _checkType passed"]
RUN --> SC{"short-circuited by<br/>.optional() / .nullable()?"}
SC -- yes --> SKIP["skipped"]
SC -- no --> FN{"predicate(value)?"}
FN -- "true → null" --> NEXT["the next post-modifier"]
FN -- "false → { issues }" --> COL["collect a CUSTOM Issue<br/>message: options.message, or the default"]
COL --> NEXT
On an object or array, the predicate sees the Schema's output: the value its fields or elements built, not the raw input.
The compiler never copies your function's body. The function is a Runtime Argument: the Compiled Schema takes it through its constructor, evaluated once where pvl.compile(...) was, and the emitted check calls it. The options object, when written as a literal, is read at compile time, so the message is written in as text.
flowchart TD
READ["readSchema: the predicate isn't a literal"] --> RA["a RuntimeArgument: arg0"]
RA --> CTOR["the class takes it in its constructor:<br/>constructor(readonly arg0: ((value: number) => value is TArg0Refined) | ((value: number) => boolean))"]
RA --> GEN["the Output type is TArg0Refined, inferred from the predicate:<br/>the class is built by a generic arrow function"]
CALL[".refine in schema.postModifierCalls"] --> TAB["_emitPostModifierChecks finds 'refine'<br/>in ChainableSchemaEmitter.postModifierChecks"]
TAB --> CHK["emitCheck: if (!this.arg0.call(undefined, value))<br/>issues.push(a CUSTOM Issue)"]
CHK --> PLACE["Frame: inside the type check's else,<br/>in chain order among the Constraints"]
pvl.compile(pvl.number().refine((value) => value % 2 === 0, { message: 'must be even' })) compiles to (shortened):
export const even = (<TArg0Refined extends number = number,>(arg0: ((value: number) => value is TArg0Refined) | ((value: number) => boolean)): PvlSchema<number, TArg0Refined> => new (class PvlCompiledSchema0 implements PvlSchema<number, TArg0Refined> {
…
constructor(readonly arg0: ((value: number) => value is TArg0Refined) | ((value: number) => boolean)) {}
…
_validate(value: unknown, path: ReadonlyArray<PropertyKey>): PvlResult<unknown> {
const issues: PvlIssue[] = [];
if (typeof value !== "number" || Number.isNaN(value)) {
issues.push({ code: "INVALID_TYPE", message: "Expected number", path: path.length > 0 ? path : undefined });
} else {
if (!this.arg0.call(undefined, value as never)) {
issues.push({ code: "CUSTOM", message: "must be even", path: path.length > 0 ? path : undefined });
}
}
return issues.length > 0 ? { issues } : { value };
}
…
})(arg0))((value) => value % 2 === 0);The predicate is passed exactly as you wrote it, so a function you imported or defined elsewhere in the file is reached as it was in the source. Its parameter is typed as the methods chained before it left the value (number, or number | null after .nullable()).
The generic arrow function around the class is there for the types only, as for .transform(): TypeScript infers TArg0Refined from the predicate it is passed, the predicate's type for a type predicate, else its default, the Output before the call (number here), so the Output narrows exactly as the interpreted one does. TArg0Refined takes the place of the Output from that call on, keeping the | null or | undefined chained before it (ADR-0051).
-
chainableInterpretedSchema.ts:refine() -
emitters/chainableSchemaEmitter.ts:refine, inpostModifierChecks,_spellParameterTypesand_spellTypeParameter -
runtimeArgument/runtimeArgument.ts:RuntimeArgument
Further reading: pipeline.md, ADR-0035.