UNPKG

n4s

Version:

typed schema validation version of enforce

158 lines (138 loc) 5.29 kB
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>]>;