validateFields with per-field error maps

TypeScript
export interface FieldRule {
  required?: boolean;
  min?: number;
  max?: number;
  pattern?: RegExp;
  label?: string;
}

/** Validate a flat payload against declarative rules and collect field errors. */
export function validateFields(
  input: Record<string, unknown>,
  rules: Record<string, FieldRule>,
): { valid: boolean; errors: Record<string, string[]> } {
  const errors: Record<string, string[]> = {};
  const add = (field: string, message: string): void => {
    (errors[field] ??= []).push(message);
  };
  for (const [field, rule] of Object.entries(rules)) {
    const label = rule.label ?? field;
    const value = input[field];
    if (value === undefined || value === null || value === '') {
      if (rule.required) add(field, `${label} is required`);
      continue;
    }
    if (typeof value === 'string') {
      if (rule.min !== undefined && value.length < rule.min) add(field, `${label} must have at least ${rule.min} characters`);
      if (rule.max !== undefined && value.length > rule.max) add(field, `${label} must have at most ${rule.max} characters`);
      if (rule.pattern && !rule.pattern.test(value)) add(field, `${label} has an invalid format`);
    } else if (typeof value === 'number') {
      if (rule.min !== undefined && value < rule.min) add(field, `${label} must be >= ${rule.min}`);
      if (rule.max !== undefined && value > rule.max) add(field, `${label} must be <= ${rule.max}`);
    } else {
      add(field, `${label} must be a string or number`);
    }
  }
  return { valid: Object.keys(errors).length === 0, errors };
}