A minimal, dependency-free validator combines discriminated results, generics, and custom type guards — the same building blocks used in production libraries like zod, scaled down.
ts
type ValidationResult<T> = { valid: true; value: T } | { valid: false; errors: string[] };
interface Validator<T> {
validate(input: unknown): ValidationResult<T>;
}
function stringValidator(): Validator<string> {
return {
validate(input) {
return typeof input === "string"
? { valid: true, value: input }
: { valid: false, errors: ["expected a string"] };
},
};
}
function objectValidator<T extends Record<string, unknown>>(
shape: { [K in keyof T]: Validator<T[K]> }
): Validator<T> {
return {
validate(input) {
if (typeof input !== "object" || input === null) return { valid: false, errors: ["expected an object"] };
const errors: string[] = [];
const result: Partial<T> = {};
for (const key in shape) {
const r = shape[key].validate((input as Record<string, unknown>)[key]);
if (r.valid) result[key] = r.value; else errors.push(`${key}: ${r.errors.join(", ")}`);
}
return errors.length ? { valid: false, errors } : { valid: true, value: result as T };
},
};
}`objectValidator`'s mapped-type parameter (`{ [K in keyof T]: Validator<T[K]> }`) ties each field's validator to the corresponding field's type in `T`, so mismatched validators are caught at compile time, not just at runtime.