Build a tiny composable schema-validation library — the seed of a tool like Zod or Yup. You expose a namespace of builders that each return a validator: s.string(), s.number(), s.boolean() for primitives, and s.object({ ... }) to describe a flat object whose fields are themselves primitive validators. Every validator answers one method — .validate(value) — returning { valid, errors }, where errors is a list of human-readable messages that is empty exactly when the value is valid. The whole point is composition: an object schema isn't a special case, it's just a map of smaller validators, each run on its own field, with the errors collected.
// A validator is any object with a validate method:
// type Result = { valid: boolean; errors: string[] }; // errors empty ⇔ valid
// type Validator = { validate(value: unknown): Result };
const s = schemaValidator; // the exported namespace of builders
s.string(): Validator // valid when typeof value === 'string'
s.number(): Validator // valid when value is a real, finite number (NaN is NOT valid)
s.boolean(): Validator // valid when value === true or value === false
// A flat object schema: each field maps to a primitive validator above.
s.object(shape: Record<string, Validator>): Validator
// Runs each field's validator against value[field] and AGGREGATES the
// errors, prefixing each with the field name, e.g. 'age: expected number'.
const s = schemaValidator;
// A primitive validator: pass and fail.
s.string().validate('hello');
// → { valid: true, errors: [] }
s.number().validate('42');
// → { valid: false, errors: ['expected number'] }
const s = schemaValidator;
// An object schema with two bad fields — BOTH are reported, each named.
const user = s.object({ name: s.string(), age: s.number(), admin: s.boolean() });
user.validate({ name: 99, age: 'old', admin: true });
// → {
// valid: false,
// errors: ['name: expected string', 'age: expected number'],
// }
{ valid, errors }. errors is a string[] that is empty when valid is true and has at least one message when valid is false. s.object depends on this consistency to combine its fields.NaN is not a valid number. typeof NaN === 'number', but it almost never means what the caller wants, so s.number().validate(NaN) must be invalid. Decide with Number.isFinite, not a bare typeof.false is a valid boolean. Don't gate on truthiness — s.boolean().validate(false) is valid. A if (value) check would wrongly reject it.undefined. If value has no age key, that field's validator receives undefined and fails with its normal type error ('age: expected number'). You don't need a separate "missing key" code path.s.object only accepts an actual object. Passing null, an array, or a primitive to an object validator is invalid — report a single top-level 'expected object' rather than crawling fields. (typeof null === 'object', so guard it explicitly.).min() / .max() constraints, and no nested object / array / optional support — those are separate, harder problems. Keep each field a single primitive validator.