Build the next layer of a tiny schema-validation library — the kind of refinement Zod and Yup are built on. The base library checked types; this one adds chainable range rules. Each builder — s.string(), s.number(), s.array() — returns a validator that exposes .min(n) and .max(n), and each of those returns the validator so the calls chain: s.string().min(3).max(10), s.number().min(0).max(100). For strings and arrays, min/max constrain length; for numbers, they constrain the value itself.
// A validator carries chainable refinements and one terminal check:
// type Result = { valid: boolean; errors: string[] }; // errors empty ⇔ valid
// type Validator = {
// min(n: number): Validator; // returns itself, so calls chain
// max(n: number): Validator; // returns itself
// validate(value: unknown): Result;
// };
const s = schemaValidator; // the exported namespace of builders
s.string(): Validator // valid when typeof value === 'string'; min/max bound LENGTH
s.number(): Validator // valid when value is a finite number; min/max bound VALUE
s.array(): Validator // valid when Array.isArray(value); min/max bound LENGTH
// Bounds are INCLUSIVE. The TYPE check runs first — if it fails, you get a single
// type error and the range checks never run.
const s = schemaValidator;
// Strings: min/max constrain length. The bound is inclusive.
s.string().min(3).validate('hi');
// → { valid: false, errors: ['expected length >= 3, got 2'] }
s.string().min(3).max(10).validate('hello');
// → { valid: true, errors: [] }
const s = schemaValidator;
// Numbers: min/max constrain the value. Type is checked FIRST.
s.number().min(0).max(100).validate(150);
// → { valid: false, errors: ['expected value <= 100, got 150'] }
s.number().min(0).validate('99');
// → { valid: false, errors: ['expected number'] } (fails on type, not range)
.min and .max must return the validator. That's what makes them chain. s.number().min(0).max(100) only works if .min(0) hands back the same validator that .max(100) then refines. Returning the bound, or a fresh object, breaks the chain.s.string() fails with 'expected string' — never with a length error. A non-string has no length to measure, so the range rules must not run once the type check has failed.s.string().min(3).validate('abc') is valid (length 3 meets min 3); s.number().max(100).validate(100) is valid. Test the exact boundary — off-by-one here is the most common bug..min(3).max(10) and .max(10).min(3) validate the same input identically. Each call just records one bound; order of recording is irrelevant..min(3) and no .max checks the floor and nothing else. A bare s.number() with no bounds checks only the type.s.object({...}) is the base schema-validator) and no nested / optional support (that's schema-validator-iii). Center the chaining and the bounds.