n4s
Version:
typed schema validation version of enforce
158 lines (138 loc) • 5.29 kB
text/typescript
import { greaterThan, isFunction, longerThan } from 'vest-utils';
import { ctx } from '../../enforceContext';
import type { RuleInstance } from '../../utils/RuleInstance';
import { RuleRunReturn } from '../../utils/RuleRunReturn';
/**
* Validates that a value is a fixed-length array (tuple) where each position
* matches the corresponding rule. Enforces exact length unless trailing
* elements use enforce.optional().
*
* Parsed values are propagated: if a rule transforms its input (e.g. toNumber),
* the parsed tuple returned via `.parse()` carries the transformed values.
*
* @param value - The array to validate
* @param rules - One RuleInstance per tuple position
* @returns RuleRunReturn indicating success or failure, with `.type` holding
* the parsed tuple on success
*
* @example
* ```typescript
* // Eager API
* enforce(['hello', 42]).tuple(enforce.isString(), enforce.isNumber());
*
* // Lazy API
* const coordSchema = enforce.tuple(enforce.isNumber(), enforce.isNumber());
* coordSchema.test([40.7, -74.0]); // true
* coordSchema.test([40.7]); // false — too few
* coordSchema.test([40.7, -74, 0]);// false — too many
* ```
*/
export function tuple(value: unknown, ...rules: any[]): RuleRunReturn<any> {
if (!Array.isArray(value)) return RuleRunReturn.Failing(value);
// Determine minimum required length (all rules minus trailing optionals)
const requiredCount = countRequired(rules);
// Reject arrays that are too short or too long
if (
greaterThan(requiredCount, value.length) ||
longerThan(value, rules.length)
) {
return RuleRunReturn.Failing(value);
}
return validateElements(value, rules);
}
/**
* Counts the number of required (non-optional) leading positions by scanning
* backwards from the end of the rules array. Stops at the first non-optional
* rule, so only *trailing* optionals reduce the required count.
*/
function countRequired(rules: any[]): number {
let count = rules.length;
for (let i = rules.length - 1; i >= 0; i--) {
if (isOptionalRule(rules[i])) count = i;
else break;
}
return count;
}
/**
* Iterates over each rule position, validates the corresponding array element,
* and collects parsed output values. Returns early on the first failing element
* with an index-based error path.
*/
function validateElements(value: any[], rules: any[]): RuleRunReturn<any> {
const parsedTuple: any[] = [];
for (let i = 0; i < rules.length; i++) {
// Skip positions beyond the array length (only reached for trailing optionals)
if (isBeyondArrayEnd(value, i, rules[i])) continue;
const res = runElementRule(value[i], rules[i], i);
if (!res.pass) return elementFailure(value, res, i);
// Use the parsed value (res.type) if the rule transformed it, otherwise keep the original
parsedTuple.push(res.type ?? value[i]);
}
return RuleRunReturn.Passing(parsedTuple);
}
/**
* Checks whether the given index is past the array's actual length
* and the corresponding rule is optional, meaning it can be skipped.
*/
function isBeyondArrayEnd(value: any[], index: number, rule: any): boolean {
return index >= value.length && isOptionalRule(rule);
}
/**
* Runs a single element's rule within an enforce context that carries
* the element value and its positional index as metadata.
*/
function runElementRule(
item: any,
rule: any,
index: number,
): RuleRunReturn<any> {
return ctx.run({ value: item, set: true, meta: { index } }, () =>
rule.run(item),
);
}
/**
* Builds a failing RuleRunReturn with an error path that includes the
* tuple index, prepended to any nested path from the inner rule failure.
* For example, a shape failure at index 1 on key "id" yields path ["1", "id"].
*/
function elementFailure(
value: any[],
res: RuleRunReturn<any>,
index: number,
): RuleRunReturn<any> {
const failure = RuleRunReturn.Failing(value, res.message);
failure.path = [index.toString(), ...(res.path || [])];
return failure;
}
/**
* Determines whether a rule is optional by testing if it passes with undefined.
* This mirrors how shape/loose detect optional fields — a rule wrapping
* enforce.optional() will pass for undefined, while required rules will not.
*/
function isOptionalRule(rule: RuleInstance<any, any>): boolean {
if (!rule || !isFunction(rule.test)) return false;
return rule.test(undefined);
}
/**
* Maps a tuple of RuleInstances to their inferred output types.
* [RuleInstance<string>, RuleInstance<number>] → [string, number]
*/
type InferTuple<T extends RuleInstance<any, any>[]> = {
[K in keyof T]: T[K] extends RuleInstance<infer R, any> ? R : never;
};
/**
* Maps a tuple of RuleInstances to their inferred input types.
* Used for the Args parameter of the returned RuleInstance so that
* .test() and .parse() accept correctly typed tuple input.
*/
type InferTupleInput<T extends RuleInstance<any, any>[]> = {
[K in keyof T]: T[K] extends RuleInstance<any, [infer A, ...any[]]>
? A
: never;
};
/**
* The RuleInstance type returned by enforce.tuple().
* Infers both output and input types from the provided rule tuple.
*/
export type TupleRuleInstance<T extends RuleInstance<any, any>[]> =
RuleInstance<InferTuple<T>, [InferTupleInput<T>]>;