From 4277f518fdf6c74c44c96064147faa2da05df01f Mon Sep 17 00:00:00 2001 From: JoelLefkowitz Date: Tue, 3 Mar 2026 15:21:05 +0000 Subject: [PATCH 1/5] Add isMapping and replace suite() with test.each --- src/factories/aliases.spec.ts | 15 --- src/factories/aliases.ts | 26 ----- src/factories/arrays.ts | 8 ++ src/factories/fallbacks.ts | 8 +- src/factories/records.spec.ts | 157 ++++++++++++++++++++++++++++++ src/factories/records.ts | 147 ++++++++++++++++++++++++++++ src/factories/results.spec.ts | 11 ++- src/factories/results.ts | 31 +++++- src/factories/transform.ts | 15 ++- src/factories/validate.spec.ts | 143 +-------------------------- src/factories/validate.ts | 110 +-------------------- src/index.ts | 39 +++++--- src/models/arrays.ts | 2 + src/models/records.ts | 11 +++ src/testing/matchers.ts | 70 ++++++------- src/testing/suites.ts | 47 --------- src/validators/arrays.spec.ts | 107 +++++++++++++------- src/validators/arrays.ts | 2 +- src/validators/numbers.spec.ts | 59 ++++++++--- src/validators/primitives.spec.ts | 153 +++++++++++++++++++++-------- src/validators/records.spec.ts | 55 +++++++---- src/validators/records.ts | 3 +- src/validators/regexes.spec.ts | 31 +++--- src/validators/strings.spec.ts | 148 ++++++++++++++++++++-------- 24 files changed, 838 insertions(+), 560 deletions(-) delete mode 100644 src/factories/aliases.spec.ts delete mode 100644 src/factories/aliases.ts create mode 100644 src/factories/records.spec.ts create mode 100644 src/factories/records.ts create mode 100644 src/models/arrays.ts delete mode 100644 src/testing/suites.ts diff --git a/src/factories/aliases.spec.ts b/src/factories/aliases.spec.ts deleted file mode 100644 index 84db015..0000000 --- a/src/factories/aliases.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { isArrayOf, isRecordOf } from "./aliases"; -import { isString } from "../validators/primitives"; - -describe("aliases", () => { - it("validates an array of inputs individually", () => { - const isArrayOfNames = isArrayOf(isRecordOf({ name: isString })); - - expect(isArrayOfNames([]).valid).toBe(true); - expect(isArrayOfNames([{ name: "a" }, { name: "b" }]).valid).toBe(true); - - expect(isArrayOfNames({}).valid).toBe(false); - expect(isArrayOfNames({ name: "a" }).valid).toBe(false); - expect(isArrayOfNames([{ name: "a" }, {}]).valid).toBe(false); - }); -}); diff --git a/src/factories/aliases.ts b/src/factories/aliases.ts deleted file mode 100644 index bf5e21d..0000000 --- a/src/factories/aliases.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { validateAll } from "./arrays"; -import { validateWith, validateWithAtLeast } from "./validate"; - -/** - * Alias for validateAll - * - * @category Aliases - * @see {@link validateAll} - */ -export const isArrayOf = validateAll; - -/** - * Alias for validateWith - * - * @category Aliases - * @see {@link validateWith} - */ -export const isRecordOf = validateWith; - -/** - * Alias for validateWithAtLeast - * - * @category Aliases - * @see {@link validateWithAtLeast} - */ -export const isRecordOfAtLeast = validateWithAtLeast; diff --git a/src/factories/arrays.ts b/src/factories/arrays.ts index 95ce80d..a5e7816 100644 --- a/src/factories/arrays.ts +++ b/src/factories/arrays.ts @@ -66,3 +66,11 @@ export const validateAll = return all(array.parsed.map(validator)); }; + +/** + * Alias for validateAll + * + * @category Aliases + * @see {@link validateAll} + */ +export const isArrayOf = validateAll; diff --git a/src/factories/fallbacks.ts b/src/factories/fallbacks.ts index 2ae9c83..ad55145 100644 --- a/src/factories/fallbacks.ts +++ b/src/factories/fallbacks.ts @@ -1,4 +1,4 @@ -import { Validator } from "../models/validators"; +import { Validated, Validator } from "../models/validators"; import { guard } from "./guards"; import { isArray } from "../validators/arrays"; @@ -37,3 +37,9 @@ export const validateEachOr = (validator: Validator, fallback: T) => (input: unknown): T[] => guard(isArray)(input) ? input.map(validateOr(validator, fallback)) : []; + +// TODO (Joel): Add a docstring here +export const validatedOr = ( + { valid, parsed }: Validated, + fallback: T, +): T => (valid ? parsed : fallback); diff --git a/src/factories/records.spec.ts b/src/factories/records.spec.ts new file mode 100644 index 0000000..170f5c5 --- /dev/null +++ b/src/factories/records.spec.ts @@ -0,0 +1,157 @@ +import { isMapping, validateWith, validateWithAtLeast } from "./records"; +import { isNaturalNumberString, isNumberString } from "../validators/strings"; +import { isNumber, isString } from "../validators/primitives"; +import { optional } from "./transform"; + +describe("validateWith", () => { + it("validates an input's fields with validators", () => { + expect( + validateWith({ + a: isNumber, + b: isNumberString, + }), + ).toValidateAs({ a: 1, b: "2" }, { a: 1, b: 2 }); + + expect( + validateWith({ + a: isNumber, + b: isString, + }), + ).toInvalidateWith("", 'Not a record: ""'); + + expect( + validateWith({ + a: isNumber, + b: isString, + }), + ).toInvalidateWith({ a: 1, b: 2 }, { b: "Not a string: 2" }); + }); + + it("requires exactly the specified fields", () => { + expect( + validateWith({ + a: isNumber, + b: isString, + }), + ).toInvalidateWith({ a: 1 }, "Missing required fields: b"); + + expect( + validateWith({ + a: isNumber, + b: isString, + }), + ).toInvalidateWith({ a: 1, b: "2", c: null }, "Unexpected extra fields: c"); + }); + + it("parses optional fields", () => { + const validator = validateWith<{ a: string; b?: number }>({ + a: isString, + b: optional(isNaturalNumberString), + }); + + expect(validator).toValidateAs({ a: "1" }, { a: "1" }); + expect( + Object.keys( + validator({ + a: "1", + }).parsed, + ), + ).toEqual(["a"]); + + expect(validator).toValidateAs({ a: "1", b: undefined }, { a: "1" }); + expect( + Object.keys( + validator({ + a: "1", + b: undefined, + }).parsed, + ), + ).toEqual(["a"]); + + expect(validator).toInvalidateWith( + { a: "1", b: 2 }, + { b: "Not a string: 2" }, + ); + + expect(validator).toValidateAs({ a: "1", b: "2" }, { a: "1", b: 2 }); + }); +}); + +describe("validateWithAtLeast", () => { + it("validates an input's fields with validators", () => { + expect( + validateWithAtLeast({ + a: isNumber, + b: isString, + }), + ).toValidateAs({ a: 1, b: "2" }, { a: 1, b: "2" }); + + expect( + validateWithAtLeast({ + a: isNumber, + b: isString, + }), + ).toInvalidateWith("", 'Not a record: ""'); + + expect( + validateWithAtLeast({ + a: isNumber, + b: isString, + }), + ).toInvalidateWith({ a: 1, b: 2 }, { b: "Not a string: 2" }); + }); + + it("allows extra fields", () => { + expect( + validateWithAtLeast({ + a: isNumber, + b: isString, + }), + ).toInvalidateWith({ a: 1 }, "Missing required fields: b"); + + expect( + validateWithAtLeast({ + a: isNumber, + b: isString, + }), + ).toValidateAs({ a: 1, b: "2", c: null }, { a: 1, b: "2", c: null }); + }); + + it("parses optional fields", () => { + const validator = validateWithAtLeast<{ a: string; b?: number }>({ + a: isString, + b: optional(isNaturalNumberString), + }); + + expect(validator).toValidateAs({ a: "1" }, { a: "1" }); + expect( + Object.keys( + validator({ + a: "1", + }).parsed, + ), + ).toEqual(["a"]); + + expect(validator).toValidateAs({ a: "1", b: undefined }, { a: "1" }); + expect( + Object.keys( + validator({ + a: "1", + b: undefined, + }).parsed, + ), + ).toEqual(["a"]); + + expect(validator).toInvalidateWith( + { a: "1", b: 2 }, + { b: "Not a string: 2" }, + ); + + expect(validator).toValidateAs({ a: "1", b: "2" }, { a: "1", b: 2 }); + }); +}); + +describe("isMapping", () => { + expect(isMapping(isString)).toValidate({ a: "1", b: "2" }); + expect(isMapping(isString)).toInvalidate({ a: "1", b: 2 }); +}); diff --git a/src/factories/records.ts b/src/factories/records.ts new file mode 100644 index 0000000..36987c2 --- /dev/null +++ b/src/factories/records.ts @@ -0,0 +1,147 @@ +import { Legend, Mapping } from "../models/records"; +import { Validated, Validator } from "../models/validators"; +import { ValidatedFields, ValidatorFields } from "../models/fields"; +import { guard } from "./guards"; +import { invalidate } from "./invalidate"; +import { isRecord } from "../validators/records"; +import { isString } from "../validators/primitives"; +import { isUndefined } from "../validators/primitives"; +import { merge } from "./results"; +import { validate } from "./validate"; + +/** + * Validate an input's fields with validators + * + * @category Factories + * @example + * validateWith({ a: isNumber, b: isString })({ a: 1, b: "2" }) >> + * { + * valid: true, + * input: { a: 1, b: "2" }, + * }; + * + * @typeParam T - The validated type + * @param validators - The validators to use + */ +export const validateWith = + (validators: ValidatorFields): Validator => + (input: unknown) => { + const record = isRecord(input); + + if (!record.valid) { + return record as Validated; + } + + const missing = Object.keys(validators).filter( + (i) => !(i in record.parsed || validators[i as keyof T](undefined).valid), + ); + + if (missing.length > 0) { + return invalidate( + input, + `Missing required fields: ${missing.join(", ")}`, + ); + } + + const extra = Object.keys(record.parsed).filter((i) => !(i in validators)); + + if (extra.length > 0) { + return invalidate(input, `Unexpected extra fields: ${extra.join(", ")}`); + } + + const validated = Object.entries(record.parsed).reduce<[string, unknown][]>( + (acc, [k, v]) => + guard(isUndefined)(v) + ? acc + : [...acc, [k, validators[k as keyof T](v)]], + [], + ); + + return merge(Object.fromEntries(validated) as ValidatedFields); + }; + +/** + * Validate an input's fields with validators allowing extra fields + * + * @category Factories + * @example + * validateWithAtLeast({ a: isNumber, b: isString })({ a: 1, b: "2" }) >> + * { + * valid: true, + * input: { a: 1, b: "2" }, + * }; + * + * @typeParam T - The validated type + * @param validators - The validators to use + */ +export const validateWithAtLeast = + (validators: ValidatorFields): Validator => + (input: unknown) => { + const record = isRecord(input); + + if (!record.valid) { + return record as Validated; + } + + const missing = Object.keys(validators).filter( + (i) => !(i in record.parsed || validators[i as keyof T](undefined).valid), + ); + + if (missing.length > 0) { + return invalidate( + input, + `Missing required fields: ${missing.join(", ")}`, + ); + } + + const validated = Object.entries(record.parsed).reduce<[string, unknown][]>( + (acc, [k, v]) => + guard(isUndefined)(v) + ? acc + : [ + ...acc, + [k, (k in validators ? validators[k as keyof T] : validate)(v)], + ], + [], + ); + + return merge(Object.fromEntries(validated) as ValidatedFields); + }; + +// TODO (Joel): Improve the README.md intro for reviewed showing isRecordOf() and the inferred type +/** + * Alias for validateWith + * + * @category Aliases + * @see {@link validateWith} + */ +export const isRecordOf = validateWith; + +/** + * Alias for validateWithAtLeast + * + * @category Aliases + * @see {@link validateWithAtLeast} + */ +export const isRecordOfAtLeast = validateWithAtLeast; + +// TODO (Joel): Add a docstring here +export const isMapping = + (validator: Validator): Validator> => + (input: unknown) => { + const record = isRecord(input); + + if (!record.valid) { + return record as Validated>; + } + + const validated = Object.entries(record.parsed).map(([k, v]) => [ + k, + validator(v), + ]); + + return merge(Object.fromEntries(validated) as ValidatedFields>); + }; + +// TODO (Joel): Add a docstring here +export const isLegend: Validator = isMapping(isString); diff --git a/src/factories/results.spec.ts b/src/factories/results.spec.ts index 1078672..311a122 100644 --- a/src/factories/results.spec.ts +++ b/src/factories/results.spec.ts @@ -1,6 +1,6 @@ -import { all, any, merge, sieve } from "./results"; +import { all, any, assert, merge, sieve } from "./results"; import { invalidate } from "./invalidate"; -import { isNumber } from "../validators/primitives"; +import { isBoolean, isNumber } from "../validators/primitives"; import { mapRecord } from "../internal/records"; describe("all", () => { @@ -83,3 +83,10 @@ describe("sieve", () => { }); }); }); + +describe("assert", () => { + it("validates an input and throws an error on failure", () => { + expect(assert(isBoolean, true)).toBe(true); + expect(() => assert(isBoolean, 0)).toThrow("Not a boolean: 0"); + }); +}); diff --git a/src/factories/results.ts b/src/factories/results.ts index cf95204..7209c17 100644 --- a/src/factories/results.ts +++ b/src/factories/results.ts @@ -1,6 +1,7 @@ -import { Validated, ValidationErrors } from "../models/validators"; +import { Validated, ValidationErrors, Validator } from "../models/validators"; import { ValidatedFields } from "../models/fields"; import { all as allPasses } from "passes"; +import { fail } from "./errors"; import { group } from "../internal/arrays"; import { invalidate, invalidateWith } from "./invalidate"; import { mapRecord, pickField, reduceRecord } from "../internal/records"; @@ -121,3 +122,31 @@ export const sieve = (results: ValidatedFields): Partial => ({ valid }) => valid, results, ) as Partial; + +// TODO (Joel): Add a docstring here +export const assert = (validator: Validator, input: unknown): T => { + const { valid, parsed, error } = validator(input); + + if (valid) { + return parsed; + } + + throw fail(error); +}; + +// TODO (Joel): Add a docstring here +export const asserts = + (validator: Validator) => + (input: unknown): T => + assert(validator, input); + +// TODO (Joel): Add a docstring here +export const when = + (validator: Validator, callback: (t: T) => void) => + (input: unknown): void => { + const { valid, parsed } = validator(input); + + if (valid) { + callback(parsed); + } + }; diff --git a/src/factories/transform.ts b/src/factories/transform.ts index bf227f8..f5d2e9b 100644 --- a/src/factories/transform.ts +++ b/src/factories/transform.ts @@ -1,4 +1,4 @@ -import { Validated, Validator } from "../models/validators"; +import { Invalid, Valid, Validated, Validator } from "../models/validators"; import { invalidateWith } from "./invalidate"; import { isUndefined } from "../validators/primitives"; import { validate } from "./validate"; @@ -145,3 +145,16 @@ export const either = export const optional = ( validator: Validator, ): Validator => either(validator, isUndefined); + +// TODO (Joel): Add a docstring here +export const chain = + (first: Validator, second: Validator) => + (input: unknown): Invalid | Invalid | Valid => { + const intermediate = first(input); + + if (!intermediate.valid) { + return intermediate; + } + + return second(intermediate.parsed); + }; diff --git a/src/factories/validate.spec.ts b/src/factories/validate.spec.ts index b408fbc..48b4de1 100644 --- a/src/factories/validate.spec.ts +++ b/src/factories/validate.spec.ts @@ -1,7 +1,4 @@ -import { isNaturalNumberString } from "../validators/strings"; -import { isNumber, isString } from "../validators/primitives"; -import { optional } from "./transform"; -import { validate, validateWith, validateWithAtLeast } from "./validate"; +import { validate } from "./validate"; describe("validate", () => { it("wraps a valid object", () => { @@ -20,141 +17,3 @@ describe("validate", () => { }); }); }); - -describe("validateWith", () => { - it("validates an input's fields with validators", () => { - expect( - validateWith({ a: isNumber, b: isString })({ a: 1, b: "2" }), - ).toEqual({ - valid: true, - input: { a: 1, b: "2" }, - parsed: { a: 1, b: "2" }, - error: null, - }); - - expect(validateWith({ a: isNumber, b: isString })("")).toEqual({ - valid: false, - input: "", - parsed: null, - error: 'Not a record: ""', - }); - - expect(validateWith({ a: isNumber, b: isString })({ a: 1, b: 2 })).toEqual({ - valid: false, - input: { a: 1, b: 2 }, - parsed: null, - error: { b: "Not a string: 2" }, - }); - }); - - it("requires exactly the specified fields", () => { - expect(validateWith({ a: isNumber, b: isString })({ a: 1 })).toEqual({ - valid: false, - input: { a: 1 }, - parsed: null, - error: "Missing required fields: b", - }); - - expect( - validateWith({ a: isNumber, b: isString })({ a: 1, b: "2", c: null }), - ).toEqual({ - valid: false, - input: { a: 1, b: "2", c: null }, - parsed: null, - error: "Unexpected extra fields: c", - }); - }); - - it("parses optional fields", () => { - const validator = validateWith<{ a: string; b?: number }>({ - a: isString, - b: optional(isNaturalNumberString), - }); - - expect(validator({ a: "1" }).valid).toBe(true); - expect(validator({ a: "1" }).parsed).toEqual({ a: "1" }); - expect(Object.keys(validator({ a: "1" }).parsed)).toEqual(["a"]); - - expect(validator({ a: "1", b: undefined }).valid).toBe(true); - expect(validator({ a: "1", b: undefined }).parsed).toEqual({ a: "1" }); - expect(Object.keys(validator({ a: "1", b: undefined }).parsed)).toEqual([ - "a", - ]); - - expect(validator({ a: "1", b: 1 }).error).toEqual({ b: "Not a string: 1" }); - expect(validator({ a: "1", b: "1" }).parsed).toEqual({ a: "1", b: 1 }); - }); -}); - -describe("validateWithAtLeast", () => { - it("validates an input's fields with validators", () => { - expect( - validateWithAtLeast({ a: isNumber, b: isString })({ a: 1, b: "2" }), - ).toEqual({ - valid: true, - input: { a: 1, b: "2" }, - parsed: { a: 1, b: "2" }, - error: null, - }); - - expect(validateWithAtLeast({ a: isNumber, b: isString })("")).toEqual({ - valid: false, - input: "", - parsed: null, - error: 'Not a record: ""', - }); - - expect( - validateWithAtLeast({ a: isNumber, b: isString })({ a: 1, b: 2 }), - ).toEqual({ - valid: false, - input: { a: 1, b: 2 }, - parsed: null, - error: { b: "Not a string: 2" }, - }); - }); - - it("allows extra fields", () => { - expect(validateWithAtLeast({ a: isNumber, b: isString })({ a: 1 })).toEqual( - { - valid: false, - input: { a: 1 }, - parsed: null, - error: "Missing required fields: b", - }, - ); - - expect( - validateWithAtLeast({ a: isNumber, b: isString })({ - a: 1, - b: "2", - c: null, - }), - ).toEqual({ - valid: true, - input: { a: 1, b: "2", c: null }, - parsed: { a: 1, b: "2", c: null }, - error: null, - }); - }); - - it("parses optional fields", () => { - const validator = validateWithAtLeast<{ a: string; b?: number }>({ - a: isString, - b: optional(isNaturalNumberString), - }); - - expect(validator({ a: "1" }).valid).toBe(true); - expect(validator({ a: "1" }).parsed).toEqual({ a: "1" }); - expect(Object.keys(validator({ a: "1" }).parsed)).toEqual(["a"]); - - expect(validator({ a: "1", b: undefined }).valid).toBe(true); - expect(validator({ a: "1", b: undefined }).parsed).toEqual({ a: "1" }); - expect(Object.keys(validator({ a: "1", b: undefined }).parsed)).toEqual([ - "a", - ]); - - expect(validator({ a: "1", b: 1 }).error).toEqual({ b: "Not a string: 1" }); - expect(validator({ a: "1", b: "1" }).parsed).toEqual({ a: "1", b: 1 }); - }); -}); diff --git a/src/factories/validate.ts b/src/factories/validate.ts index a23e90c..5127be9 100644 --- a/src/factories/validate.ts +++ b/src/factories/validate.ts @@ -1,9 +1,4 @@ -import { Validated, Validator } from "../models/validators"; -import { ValidatedFields, ValidatorFields } from "../models/fields"; -import { invalidate } from "./invalidate"; -import { isRecord } from "../validators/records"; -import { isUndefined } from "../validators/primitives"; -import { merge } from "./results"; +import { Validated } from "../models/validators"; /** * Validate an input @@ -39,106 +34,3 @@ export const validate = ( parsed: parsed as T, error: null, }); - -/** - * Validate an input's fields with validators - * - * @category Factories - * @example - * validateWith({ a: isNumber, b: isString })({ a: 1, b: "2" }) >> - * { - * valid: true, - * input: { a: 1, b: "2" }, - * }; - * - * @typeParam T - The validated type - * @param validators - The validators to use - */ -export const validateWith = - (validators: ValidatorFields): Validator => - (input: unknown) => { - const record = isRecord(input); - - if (!record.valid) { - return record as Validated; - } - - const isOptional = (validator: Validator) => - validator(undefined).valid; - - const missing = Object.keys(validators).filter( - (i) => !(i in record.parsed || isOptional(validators[i as keyof T])), - ); - - if (missing.length > 0) { - return invalidate( - input, - `Missing required fields: ${missing.join(", ")}`, - ); - } - - const extra = Object.keys(record.parsed).filter((i) => !(i in validators)); - - if (extra.length > 0) { - return invalidate(input, `Unexpected extra fields: ${extra.join(", ")}`); - } - - const validated = Object.entries(record.parsed).reduce<[string, unknown][]>( - (acc, [k, v]) => - isUndefined(v).valid ? acc : [...acc, [k, validators[k as keyof T](v)]], - [], - ); - - return merge(Object.fromEntries(validated) as ValidatedFields); - }; - -/** - * Validate an input's fields with validators allowing extra fields - * - * @category Factories - * @example - * validateWithAtLeast({ a: isNumber, b: isString })({ a: 1, b: "2" }) >> - * { - * valid: true, - * input: { a: 1, b: "2" }, - * }; - * - * @typeParam T - The validated type - * @param validators - The validators to use - */ -export const validateWithAtLeast = - (validators: ValidatorFields): Validator => - (input: unknown) => { - const record = isRecord(input); - - if (!record.valid) { - return record as Validated; - } - - const isOptional = (validator: Validator) => - validator(undefined).valid; - - const missing = Object.keys(validators).filter( - (i) => !(i in record.parsed || isOptional(validators[i as keyof T])), - ); - - if (missing.length > 0) { - return invalidate( - input, - `Missing required fields: ${missing.join(", ")}`, - ); - } - - const validated = Object.entries(record.parsed).reduce<[string, unknown][]>( - (acc, [k, v]) => - isUndefined(v).valid - ? acc - : [ - ...acc, - [k, (k in validators ? validators[k as keyof T] : validate)(v)], - ], - [], - ); - - return merge(Object.fromEntries(validated) as ValidatedFields); - }; diff --git a/src/index.ts b/src/index.ts index 24f2d32..80e0891 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,10 @@ -export { Annotated, RecordLike } from "./models/records"; +export { + Annotated, + Legend, + Mapping, + Optional, + RecordLike, +} from "./models/records"; export { ArrayFields, ValidatedFields, ValidatorFields } from "./models/fields"; export { Guard, @@ -9,9 +15,18 @@ export { Validator, } from "./models/validators"; export { IJSON } from "./models/json"; +export { Listed } from "./models/arrays"; export { RegexResult, RegexValidator } from "./models/regexes"; -export { all, any, merge, sieve } from "./factories/results"; -export { both, either, not, optional } from "./factories/transform"; +export { + all, + any, + assert, + asserts, + merge, + sieve, + when, +} from "./factories/results"; +export { both, chain, either, not, optional } from "./factories/transform"; export { fail } from "./factories/errors"; export { filterValid } from "./factories/filter"; export { fromGuard, guard } from "./factories/guards"; @@ -24,7 +39,7 @@ export { isOneOf, isStringArray, } from "./validators/arrays"; -export { isArrayOf, isRecordOf, isRecordOfAtLeast } from "./factories/aliases"; +export { isArrayOf, validateAll, validateEach } from "./factories/arrays"; export { isBoolean, isNull, @@ -42,15 +57,17 @@ export { } from "./validators/strings"; export { isEmail } from "./validators/regexes"; export { isInteger, isNaturalNumber } from "./validators/numbers"; -export { isNonEmptyRecord, isRecord } from "./validators/records"; -export { serialize } from "./services/strings"; -export { suite } from "./testing/suites"; export { - validate, + isLegend, + isMapping, + isRecordOf, + isRecordOfAtLeast, validateWith, validateWithAtLeast, -} from "./factories/validate"; -export { validateAll, validateEach } from "./factories/arrays"; -export { validateEachOr, validateOr } from "./factories/fallbacks"; +} from "./factories/records"; +export { isNonEmptyRecord, isRecord } from "./validators/records"; +export { serialize } from "./services/strings"; +export { validate } from "./factories/validate"; +export { validateEachOr, validateOr, validatedOr } from "./factories/fallbacks"; export { validateIf } from "./factories/conditionals"; export { validateRegex } from "./factories/regexes"; diff --git a/src/models/arrays.ts b/src/models/arrays.ts new file mode 100644 index 0000000..d27b5b2 --- /dev/null +++ b/src/models/arrays.ts @@ -0,0 +1,2 @@ +/** @typeParam T - The array type */ +export type Listed = T[number]; diff --git a/src/models/records.ts b/src/models/records.ts index 05fe611..c7c1d99 100644 --- a/src/models/records.ts +++ b/src/models/records.ts @@ -1,3 +1,14 @@ +/** @typeParam T - The value type */ +export type Mapping = Record; + +export type Legend = Mapping; + +/** @typeParam T - The record type */ +/** @typeParam K - The optional type union */ +export type Optional = Omit & { + [K in keyof T]?: T[K]; +}; + /** @typeParam T - The record type */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export type RecordLike = (T extends Record ? unknown : never) & diff --git a/src/testing/matchers.ts b/src/testing/matchers.ts index 42e422f..106c21f 100644 --- a/src/testing/matchers.ts +++ b/src/testing/matchers.ts @@ -5,7 +5,6 @@ import { didNotValidate, didNotValidateAs, } from "./messages"; -import { invalidateWith } from "../factories/invalidate"; /** @internal */ const shallowEqual = (a: unknown, b: unknown) => @@ -29,49 +28,44 @@ export const toValidate = (validator: Validator, input: unknown) => { }; /** - * Assert that a validator fails a given input + * Assert that a validator passes a given input with an expected parsed output * * @category Testing * @example - * expect(isNaturalNumberString).toInvalidate({}); + * expect(isNaturalNumberString).toValidateAs("1", 1); * * @param input - The raw input */ -export const toInvalidate = (validator: Validator, input: unknown) => { - const { valid } = validator(input); +export const toValidateAs = ( + validator: Validator, + input: unknown, + expected: unknown, +) => { + const { valid, parsed, error } = validator(input); return { - pass: !valid, - message: () => didNotInvalidate(input), + pass: valid && shallowEqual(parsed, expected), + message: () => + valid + ? didNotValidateAs(input, expected, parsed) + : didNotValidate(input, error), }; }; /** - * Assert that a validator passes a given input with an expected parsed output + * Assert that a validator fails a given input * * @category Testing * @example - * expect(isNaturalNumberString).toValidateAs("1", 1); + * expect(isNaturalNumberString).toInvalidate({}); * * @param input - The raw input */ -export const toValidateAs = ( - validator: Validator, - input: unknown, - expected: unknown, -) => { - const { valid, parsed, error } = validator(input); - return valid && shallowEqual(parsed, expected) - ? { - pass: true, - message: () => "", - } - : { - pass: false, - message: () => - valid - ? didNotValidateAs(input, expected, parsed) - : didNotValidate(input, error), - }; +export const toInvalidate = (validator: Validator, input: unknown) => { + const { valid } = validator(input); + return { + pass: !valid, + message: () => didNotInvalidate(input), + }; }; /** @@ -86,22 +80,16 @@ export const toValidateAs = ( export const toInvalidateWith = ( validator: Validator, input: unknown, - reason: string, + expected: string, ) => { const { valid, error } = validator(input); - const { error: expected } = invalidateWith(reason)(input); - return !valid && error == expected - ? { - pass: true, - message: () => "", - } - : { - pass: false, - message: () => - valid - ? didNotInvalidate(input) - : didNotInvalidateWith(input, expected, error), - }; + return { + pass: !valid && shallowEqual(error, expected), + message: () => + valid + ? didNotInvalidate(input) + : didNotInvalidateWith(input, expected, error), + }; }; /** diff --git a/src/testing/suites.ts b/src/testing/suites.ts deleted file mode 100644 index 924e74a..0000000 --- a/src/testing/suites.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Validator } from "../models/validators"; - -/** - * Construct a test suite for a validator - * - * @category Testing - * @example - * suite( - * isStringArray, - * [ - * { input: [], parsed: [] }, - * { input: ["a"], parsed: ["a"] }, - * ], - * { - * "Not an array": [undefined, null, true, 1, "a", {}], - * "Not an array of strings": [[1]], - * }, - * ); - * - * @typeParam T - The validated type - * @param validator - The validator to test - * @param valid - An array of valid raw and parsed inputs - * @param invalid - A mapping of validation errors and invalid inputs - * @param name - The name of the validator to test - */ -export const suite = ( - validator: Validator, - valid: { input: unknown; parsed: T }[], - invalid: Record, - name: string = validator.name, -): void => { - describe(name, () => { - it("passes a valid objects", () => { - valid.forEach(({ input, parsed }) => { - expect(validator).toValidateAs(input, parsed); - }); - }); - - it("fails invalid objects", () => { - Object.entries(invalid).forEach(([reason, cases]) => { - cases.forEach((input) => { - expect(validator).toInvalidateWith(input, reason); - }); - }); - }); - }); -}; diff --git a/src/validators/arrays.spec.ts b/src/validators/arrays.spec.ts index 08f5d5e..c4e372f 100644 --- a/src/validators/arrays.spec.ts +++ b/src/validators/arrays.spec.ts @@ -1,52 +1,94 @@ import { - isArray, isManyOf, + isOneOf, + isArray, isNonEmptyArray, isNumberArray, - isOneOf, isStringArray, } from "./arrays"; -import { suite } from "../testing/suites"; -suite( - isArray, - [ +describe("isArray", () => { + test.each([ { input: [], parsed: [] }, { input: [1], parsed: [1] }, - ], - { - "Not an array": [undefined, null, true, 1, "a", {}], - }, -); + ])("validates $input as array", ({ input, parsed }) => { + expect(isArray).toValidateAs(input, parsed); + }); -suite(isNonEmptyArray, [{ input: [1], parsed: [1] }], { - "Not an array": [undefined, null, true, 1, "a", {}], - "Not a non empty array": [[]], + test.each([ + { input: undefined, error: 'Not an array: "undefined"' }, + { input: null, error: "Not an array: null" }, + { input: true, error: "Not an array: true" }, + { input: 1, error: "Not an array: 1" }, + { input: "a", error: 'Not an array: "a"' }, + { input: {}, error: "Not an array: {}" }, + ])('fails $input with "$error"', ({ input, error }) => { + expect(isArray).toInvalidateWith(input, error); + }); }); -suite( - isNumberArray, - [ +describe("isNonEmptyArray", () => { + test.each([{ input: [1], parsed: [1] }])( + "validates $input as non-empty array", + ({ input, parsed }) => { + expect(isNonEmptyArray).toValidateAs(input, parsed); + }, + ); + + test.each([ + { input: undefined, error: 'Not an array: "undefined"' }, + { input: null, error: "Not an array: null" }, + { input: true, error: "Not an array: true" }, + { input: 1, error: "Not an array: 1" }, + { input: "a", error: 'Not an array: "a"' }, + { input: {}, error: "Not an array: {}" }, + { input: [], error: "Not a non empty array: []" }, + ])('fails $input with "$error"', ({ input, error }) => { + expect(isNonEmptyArray).toInvalidateWith(input, error); + }); +}); + +describe("isNumberArray", () => { + test.each([ { input: [], parsed: [] }, { input: [1], parsed: [1] }, - ], - { - "Not an array": [undefined, null, true, 1, "a", {}], - "Not an array of numbers": [["a"]], - }, -); + ])("validates $input as number array", ({ input, parsed }) => { + expect(isNumberArray).toValidateAs(input, parsed); + }); -suite( - isStringArray, - [ + test.each([ + { input: undefined, error: 'Not an array: "undefined"' }, + { input: null, error: "Not an array: null" }, + { input: true, error: "Not an array: true" }, + { input: 1, error: "Not an array: 1" }, + { input: "a", error: 'Not an array: "a"' }, + { input: {}, error: "Not an array: {}" }, + { input: ["a"], error: 'Not an array of numbers: ["a"]' }, + ])('fails $input with "$error"', ({ input, error }) => { + expect(isNumberArray).toInvalidateWith(input, error); + }); +}); + +describe("isStringArray", () => { + test.each([ { input: [], parsed: [] }, { input: ["a"], parsed: ["a"] }, - ], - { - "Not an array": [undefined, null, true, 1, "a", {}], - "Not an array of strings": [[1]], - }, -); + ])("validates $input as string array", ({ input, parsed }) => { + expect(isStringArray).toValidateAs(input, parsed); + }); + + test.each([ + { input: undefined, error: 'Not an array: "undefined"' }, + { input: null, error: "Not an array: null" }, + { input: true, error: "Not an array: true" }, + { input: 1, error: "Not an array: 1" }, + { input: "a", error: 'Not an array: "a"' }, + { input: {}, error: "Not an array: {}" }, + { input: [1], error: "Not an array of strings: [1]" }, + ])('fails $input with "$error"', ({ input, error }) => { + expect(isStringArray).toInvalidateWith(input, error); + }); +}); describe("isOneOf", () => { it("validates if an object is a value from a set of options", () => { @@ -58,7 +100,6 @@ describe("isOneOf", () => { describe("isManyOf", () => { it("validates if an array contains only values from a set of options", () => { expect(isManyOf([1, 2, 3])([3, 1]).parsed).toEqual([3, 1]); - expect(isManyOf([1, 2, 3])("").error).toBe('Not an array: ""'); expect(isManyOf([1, 2, 3])([3, 1, 4]).error).toBe( "Not an array of [1, 2, 3]: [3, 1, 4]", diff --git a/src/validators/arrays.ts b/src/validators/arrays.ts index ecba5e0..50db3b5 100644 --- a/src/validators/arrays.ts +++ b/src/validators/arrays.ts @@ -114,7 +114,7 @@ export const isNumberArray: Validator = (input: unknown) => { } return validateIf( - array.parsed.every((i) => isNumber(i).valid), + array.parsed.every(guard(isNumber)), input, input, "Not an array of numbers", diff --git a/src/validators/numbers.spec.ts b/src/validators/numbers.spec.ts index e3c4a96..d65d0d6 100644 --- a/src/validators/numbers.spec.ts +++ b/src/validators/numbers.spec.ts @@ -1,21 +1,52 @@ import { isInteger, isNaturalNumber } from "./numbers"; -import { suite } from "../testing/suites"; -suite( - isInteger, - [ +describe("isInteger", () => { + test.each([ { input: -1, parsed: -1 }, { input: 0, parsed: 0 }, { input: 1, parsed: 1 }, - ], - { - "Not a number": [undefined, null, true, "", "a", [], {}, NaN, Infinity], - "Not an integer": [0.5], - }, -); + ])("validates $input as $parsed", ({ input, parsed }) => { + expect(isInteger).toValidateAs(input, parsed); + }); -suite(isNaturalNumber, [{ input: 1, parsed: 1 }], { - "Not a number": [undefined, null, true, "", "a", [], {}, NaN, Infinity], - "Not an integer": [0.5], - "Not a natural number": [-1, 0], + test.each([ + { input: undefined, error: 'Not a number: "undefined"' }, + { input: null, error: "Not a number: null" }, + { input: true, error: "Not a number: true" }, + { input: "", error: 'Not a number: ""' }, + { input: "a", error: 'Not a number: "a"' }, + { input: [], error: "Not a number: []" }, + { input: {}, error: "Not a number: {}" }, + { input: NaN, error: 'Not a number: "NaN"' }, + { input: Infinity, error: 'Not a number: "Infinity"' }, + { input: 0.5, error: "Not an integer: 0.5" }, + ])('fails $input with "$error"', ({ input, error }) => { + expect(isInteger).toInvalidateWith(input, error); + }); +}); + +describe("isNaturalNumber", () => { + test.each([{ input: 1, parsed: 1 }])( + "validates $input as $parsed", + ({ input, parsed }) => { + expect(isNaturalNumber).toValidateAs(input, parsed); + }, + ); + + test.each([ + { input: undefined, error: 'Not a number: "undefined"' }, + { input: null, error: "Not a number: null" }, + { input: true, error: "Not a number: true" }, + { input: "", error: 'Not a number: ""' }, + { input: "a", error: 'Not a number: "a"' }, + { input: [], error: "Not a number: []" }, + { input: {}, error: "Not a number: {}" }, + { input: NaN, error: 'Not a number: "NaN"' }, + { input: Infinity, error: 'Not a number: "Infinity"' }, + { input: 0.5, error: "Not an integer: 0.5" }, + { input: -1, error: "Not a natural number: -1" }, + { input: 0, error: "Not a natural number: 0" }, + ])('fails $input with "$error"', ({ input, error }) => { + expect(isNaturalNumber).toInvalidateWith(input, error); + }); }); diff --git a/src/validators/primitives.spec.ts b/src/validators/primitives.spec.ts index ffe20b6..1cd9a02 100644 --- a/src/validators/primitives.spec.ts +++ b/src/validators/primitives.spec.ts @@ -1,61 +1,134 @@ import { - isBoolean, + isUndefined, isNull, + isBoolean, isNumber, - isObject, isString, - isUndefined, + isObject, } from "./primitives"; -import { suite } from "../testing/suites"; -suite(isUndefined, [{ input: undefined, parsed: undefined }], { - "Not undefined": [null, true, 1, "", "a", [], {}], +describe("isUndefined", () => { + test.each([{ input: undefined, parsed: undefined }])( + "validates $input as $parsed", + ({ input, parsed }) => { + expect(isUndefined).toValidateAs(input, parsed); + }, + ); + + test.each([ + { input: null, error: "Not undefined: null" }, + { input: true, error: "Not undefined: true" }, + { input: 1, error: "Not undefined: 1" }, + { input: "", error: 'Not undefined: ""' }, + { input: "a", error: 'Not undefined: "a"' }, + { input: [], error: "Not undefined: []" }, + { input: {}, error: "Not undefined: {}" }, + ])('fails $input with "$error"', ({ input, error }) => { + expect(isUndefined).toInvalidateWith(input, error); + }); }); -suite(isNull, [{ input: null, parsed: null }], { - "Not null": [undefined, true, 1, "", "a", [], {}], +describe("isNull", () => { + test.each([{ input: null, parsed: null }])( + "validates $input as $parsed", + ({ input, parsed }) => { + expect(isNull).toValidateAs(input, parsed); + }, + ); + + test.each([ + { input: undefined, error: 'Not null: "undefined"' }, + { input: true, error: "Not null: true" }, + { input: 1, error: "Not null: 1" }, + { input: "", error: 'Not null: ""' }, + { input: "a", error: 'Not null: "a"' }, + { input: [], error: "Not null: []" }, + { input: {}, error: "Not null: {}" }, + ])('fails $input with "$error"', ({ input, error }) => { + expect(isNull).toInvalidateWith(input, error); + }); }); -suite( - isBoolean, - [ +describe("isBoolean", () => { + test.each([ { input: true, parsed: true }, { input: false, parsed: false }, - ], - { - "Not a boolean": [undefined, null, 1, "", "a", [], {}], - }, -); + ])("validates $input as $parsed", ({ input, parsed }) => { + expect(isBoolean).toValidateAs(input, parsed); + }); -suite( - isNumber, - [ + test.each([ + { input: undefined, error: 'Not a boolean: "undefined"' }, + { input: null, error: "Not a boolean: null" }, + { input: 1, error: "Not a boolean: 1" }, + { input: "", error: 'Not a boolean: ""' }, + { input: "a", error: 'Not a boolean: "a"' }, + { input: [], error: "Not a boolean: []" }, + { input: {}, error: "Not a boolean: {}" }, + ])('fails $input with "$error"', ({ input, error }) => { + expect(isBoolean).toInvalidateWith(input, error); + }); +}); + +describe("isNumber", () => { + test.each([ { input: 0, parsed: 0 }, { input: 1, parsed: 1 }, - ], - { - "Not a number": [undefined, null, true, NaN, Infinity, "", "a", [], {}], - }, -); + ])("validates $input as $parsed", ({ input, parsed }) => { + expect(isNumber).toValidateAs(input, parsed); + }); -suite( - isString, - [ + test.each([ + { input: undefined, error: 'Not a number: "undefined"' }, + { input: null, error: "Not a number: null" }, + { input: true, error: "Not a number: true" }, + { input: NaN, error: 'Not a number: "NaN"' }, + { input: Infinity, error: 'Not a number: "Infinity"' }, + { input: "", error: 'Not a number: ""' }, + { input: "a", error: 'Not a number: "a"' }, + { input: [], error: "Not a number: []" }, + { input: {}, error: "Not a number: {}" }, + ])('fails $input with "$error"', ({ input, error }) => { + expect(isNumber).toInvalidateWith(input, error); + }); +}); + +describe("isString", () => { + test.each([ { input: "", parsed: "" }, { input: "a", parsed: "a" }, - ], - { - "Not a string": [undefined, null, true, 1, [], {}], - }, -); + ])("validates $input as $parsed", ({ input, parsed }) => { + expect(isString).toValidateAs(input, parsed); + }); -suite( - isObject, - [ + test.each([ + { input: undefined, error: 'Not a string: "undefined"' }, + { input: null, error: "Not a string: null" }, + { input: true, error: "Not a string: true" }, + { input: 1, error: "Not a string: 1" }, + { input: [], error: "Not a string: []" }, + { input: {}, error: "Not a string: {}" }, + ])('fails $input with "$error"', ({ input, error }) => { + expect(isString).toInvalidateWith(input, error); + }); +}); + +describe("isObject", () => { + test.each([ { input: [], parsed: [] }, { input: { a: 1 }, parsed: { a: 1 } }, - ], - { - "Not an object": [undefined, null, true, 1, NaN, Infinity], - }, -); + ])("validates $input as $parsed", ({ input, parsed }) => { + expect(isObject).toValidateAs(input, parsed); + }); + + test.each([ + { input: undefined, error: 'Not an object: "undefined"' }, + { input: null, error: "Not an object: null" }, + { input: true, error: "Not an object: true" }, + { input: 1, error: "Not an object: 1" }, + { input: NaN, error: 'Not an object: "NaN"' }, + { input: Infinity, error: 'Not an object: "Infinity"' }, + ])('fails $input with "$error"', ({ input, error }) => { + expect(isObject).toInvalidateWith(input, error); + }); +}); diff --git a/src/validators/records.spec.ts b/src/validators/records.spec.ts index ead0515..a245610 100644 --- a/src/validators/records.spec.ts +++ b/src/validators/records.spec.ts @@ -1,28 +1,45 @@ -import { isNonEmptyRecord, isRecord } from "./records"; -import { suite } from "../testing/suites"; +import { isRecord, isNonEmptyRecord } from "./records"; -suite( - isRecord, - [ +describe("isRecord", () => { + test.each([ { input: {}, parsed: {} }, { input: { a: 1 }, parsed: { a: 1 } }, { input: { a: "a" }, parsed: { a: "a" } }, { input: { 1: "a" }, parsed: { 1: "a" } }, - ], - { - "Not a record": [undefined, null, true, 1, "a", []], - }, -); + ])("validates $input as record", ({ input, parsed }) => { + expect(isRecord).toValidateAs(input, parsed); + }); -suite( - isNonEmptyRecord, - [ + test.each([ + { input: undefined, error: 'Not a record: "undefined"' }, + { input: null, error: "Not a record: null" }, + { input: true, error: "Not a record: true" }, + { input: 1, error: "Not a record: 1" }, + { input: "a", error: 'Not a record: "a"' }, + { input: [], error: "Not a record: []" }, + ])('fails $input with "$error"', ({ input, error }) => { + expect(isRecord).toInvalidateWith(input, error); + }); +}); + +describe("isNonEmptyRecord", () => { + test.each([ { input: { a: 1 }, parsed: { a: 1 } }, { input: { a: "a" }, parsed: { a: "a" } }, { input: { 1: "a" }, parsed: { 1: "a" } }, - ], - { - "Not a record": [undefined, null, true, 1, "a", []], - "Not a non empty record": [{}], - }, -); + ])("validates $input as non-empty record", ({ input, parsed }) => { + expect(isNonEmptyRecord).toValidateAs(input, parsed); + }); + + test.each([ + { input: undefined, error: 'Not a record: "undefined"' }, + { input: null, error: "Not a record: null" }, + { input: true, error: "Not a record: true" }, + { input: 1, error: "Not a record: 1" }, + { input: "a", error: 'Not a record: "a"' }, + { input: [], error: "Not a record: []" }, + { input: {}, error: "Not a non empty record: {}" }, + ])('fails $input with "$error"', ({ input, error }) => { + expect(isNonEmptyRecord).toInvalidateWith(input, error); + }); +}); diff --git a/src/validators/records.ts b/src/validators/records.ts index 12c3c5a..af98a7e 100644 --- a/src/validators/records.ts +++ b/src/validators/records.ts @@ -1,4 +1,5 @@ import { Validator } from "../models/validators"; +import { guard } from "../factories/guards"; import { isArray } from "./arrays"; import { isObject } from "./primitives"; import { validateIf } from "../factories/conditionals"; @@ -25,7 +26,7 @@ import { validateIf } from "../factories/conditionals"; */ export const isRecord: Validator> = (input: unknown) => validateIf( - isObject(input).valid && !isArray(input).valid, + guard(isObject)(input) && !guard(isArray)(input), input, input, "Not a record", diff --git a/src/validators/regexes.spec.ts b/src/validators/regexes.spec.ts index d8db44c..6cf8da6 100644 --- a/src/validators/regexes.spec.ts +++ b/src/validators/regexes.spec.ts @@ -1,9 +1,7 @@ import { isEmail } from "./regexes"; -import { suite } from "../testing/suites"; -suite( - isEmail, - [ +describe("isEmail", () => { + test.each([ { input: "user@domain.com", parsed: { @@ -31,14 +29,17 @@ suite( named: { user: "user", domain: "sub.domain.com" }, }, }, - ], - { - "Not an email": [ - "user", - "@domain.com", - "user@domain", - "user-domain.com", - "user~@domain.com", - ], - }, -); + ])("validates $input as email", ({ input, parsed }) => { + expect(isEmail).toValidateAs(input, parsed); + }); + + test.each([ + { input: "user", error: 'Not an email: "user"' }, + { input: "@domain.com", error: 'Not an email: "@domain.com"' }, + { input: "user@domain", error: 'Not an email: "user@domain"' }, + { input: "user-domain.com", error: 'Not an email: "user-domain.com"' }, + { input: "user~@domain.com", error: 'Not an email: "user~@domain.com"' }, + ])('fails $input with "$error"', ({ input, error }) => { + expect(isEmail).toInvalidateWith(input, error); + }); +}); diff --git a/src/validators/strings.spec.ts b/src/validators/strings.spec.ts index 26d27fb..920385d 100644 --- a/src/validators/strings.spec.ts +++ b/src/validators/strings.spec.ts @@ -1,59 +1,125 @@ import { isBooleanString, + isNumberString, isIntegerString, - isJSONString, isNaturalNumberString, - isNumberString, + isJSONString, } from "./strings"; -import { suite } from "../testing/suites"; -suite( - isBooleanString, - [ +describe("isBooleanString", () => { + test.each([ { input: "true", parsed: true }, { input: "false", parsed: false }, - ], - { - "Not a string": [undefined, null, true, 1, [], {}], - "Not a boolean string": ["", "1", "a"], - }, -); - -suite( - isNumberString, - [ + ])("validates $input as $parsed", ({ input, parsed }) => { + expect(isBooleanString).toValidateAs(input, parsed); + }); + + test.each([ + { input: undefined, error: 'Not a string: "undefined"' }, + { input: null, error: "Not a string: null" }, + { input: true, error: "Not a string: true" }, + { input: 1, error: "Not a string: 1" }, + { input: [], error: "Not a string: []" }, + { input: {}, error: "Not a string: {}" }, + ])('fails $input with "$error"', ({ input, error }) => { + expect(isBooleanString).toInvalidateWith(input, error); + }); +}); + +describe("isNumberString", () => { + test.each([ { input: "-1", parsed: -1 }, { input: "0", parsed: 0 }, { input: "0.5", parsed: 0.5 }, { input: "1", parsed: 1 }, - ], - { - "Not a string": [undefined, null, true, 1, [], {}], - "Not a number string": ["", "true", "a", "NaN", "Infinity"], - }, -); - -suite( - isIntegerString, - [ + ])("validates $input as $parsed", ({ input, parsed }) => { + expect(isNumberString).toValidateAs(input, parsed); + }); + + test.each([ + { input: undefined, error: 'Not a string: "undefined"' }, + { input: null, error: "Not a string: null" }, + { input: true, error: "Not a string: true" }, + { input: 1, error: "Not a string: 1" }, + { input: [], error: "Not a string: []" }, + { input: {}, error: "Not a string: {}" }, + { input: "", error: 'Not a number string: ""' }, + { input: "true", error: 'Not a number string: "true"' }, + { input: "a", error: 'Not a number string: "a"' }, + { input: "NaN", error: 'Not a number string: "NaN"' }, + { input: "Infinity", error: 'Not a number string: "Infinity"' }, + ])('fails $input with "$error"', ({ input, error }) => { + expect(isNumberString).toInvalidateWith(input, error); + }); +}); + +describe("isIntegerString", () => { + test.each([ { input: "-1", parsed: -1 }, { input: "0", parsed: 0 }, { input: "1", parsed: 1 }, - ], - { - "Not a string": [undefined, null, true, 1, [], {}], - "Not a number string": ["", "true", "a", "NaN", "Infinity"], - "Not an integer string": ["0.5"], - }, -); - -suite(isNaturalNumberString, [{ input: "1", parsed: 1 }], { - "Not a string": [undefined, null, true, 1, [], {}], - "Not a number string": ["", "true", "a", "NaN", "Infinity"], - "Not an integer string": ["0.5"], - "Not a natural number string": ["0", "-1"], + ])("validates $input as $parsed", ({ input, parsed }) => { + expect(isIntegerString).toValidateAs(input, parsed); + }); + + test.each([ + { input: undefined, error: 'Not a string: "undefined"' }, + { input: null, error: "Not a string: null" }, + { input: true, error: "Not a string: true" }, + { input: 1, error: "Not a string: 1" }, + { input: [], error: "Not a string: []" }, + { input: {}, error: "Not a string: {}" }, + { input: "", error: 'Not a number string: ""' }, + { input: "true", error: 'Not a number string: "true"' }, + { input: "a", error: 'Not a number string: "a"' }, + { input: "NaN", error: 'Not a number string: "NaN"' }, + { input: "Infinity", error: 'Not a number string: "Infinity"' }, + { input: "0.5", error: 'Not an integer string: "0.5"' }, + ])('fails $input with "$error"', ({ input, error }) => { + expect(isIntegerString).toInvalidateWith(input, error); + }); }); -suite(isJSONString, [{ input: '{"a": 1}', parsed: { a: 1 } }], { - "Not JSON": ["_"], +describe("isNaturalNumberString", () => { + test.each([{ input: "1", parsed: 1 }])( + "validates $input as $parsed", + ({ input, parsed }) => { + expect(isNaturalNumberString).toValidateAs(input, parsed); + }, + ); + + test.each([ + { input: undefined, error: 'Not a string: "undefined"' }, + { input: null, error: "Not a string: null" }, + { input: true, error: "Not a string: true" }, + { input: 1, error: "Not a string: 1" }, + { input: [], error: "Not a string: []" }, + { input: {}, error: "Not a string: {}" }, + { input: "", error: 'Not a number string: ""' }, + { input: "true", error: 'Not a number string: "true"' }, + { input: "a", error: 'Not a number string: "a"' }, + { input: "NaN", error: 'Not a number string: "NaN"' }, + { input: "Infinity", error: 'Not a number string: "Infinity"' }, + { input: "0.5", error: 'Not an integer string: "0.5"' }, + { input: "0", error: 'Not a natural number string: "0"' }, + { input: "-1", error: 'Not a natural number string: "-1"' }, + ])('fails $input with "$error"', ({ input, error }) => { + expect(isNaturalNumberString).toInvalidateWith(input, error); + }); +}); + +describe("isJSONString", () => { + test.each([{ input: '{"a": 1}', parsed: { a: 1 } }])( + "validates $input as $parsed", + ({ input, parsed }) => { + expect(isJSONString).toValidateAs(input, parsed); + }, + ); + + test.each([{ input: "_", error: 'Not JSON: "_"' }])( + 'fails $input with "$error"', + ({ input, error }) => { + expect(isJSONString).toInvalidateWith(input, error); + }, + ); }); From 9dcf209e31571543ee63007e21afecef105dd33b Mon Sep 17 00:00:00 2001 From: JoelLefkowitz Date: Wed, 4 Mar 2026 11:49:40 +0000 Subject: [PATCH 2/5] Add validates --- README.md | 18 +- jest.config.json | 17 +- src/factories/arrays.ts | 2 +- src/factories/fallbacks.spec.ts | 9 +- src/factories/fallbacks.ts | 12 +- src/factories/guards.spec.ts | 7 +- src/factories/records.spec.ts | 163 ++------ src/factories/records.ts | 2 +- src/factories/results.spec.ts | 92 ----- src/factories/transform.spec.ts | 67 ---- src/index.ts | 98 ++--- src/internal/records.ts | 4 + src/services/results.spec.ts | 105 +++++ src/{factories => services}/results.ts | 34 +- src/services/strings.spec.ts | 6 +- src/services/transformers.spec.ts | 66 ++++ .../transform.ts => services/transformers.ts} | 11 +- src/testing/cases.ts | 15 + src/testing/{jest.ts => globals.ts} | 12 +- src/testing/matchers.ts | 29 +- src/testing/messages.ts | 6 +- src/validators/arrays.spec.ts | 255 ++++++++---- src/validators/numbers.spec.ts | 162 +++++--- src/validators/primitives.spec.ts | 361 +++++++++++------ src/validators/records.spec.ts | 135 +++++-- src/validators/regexes.spec.ts | 96 +++-- src/validators/strings.spec.ts | 363 +++++++++++++----- 27 files changed, 1275 insertions(+), 872 deletions(-) delete mode 100644 src/factories/results.spec.ts delete mode 100644 src/factories/transform.spec.ts create mode 100644 src/services/results.spec.ts rename src/{factories => services}/results.ts (92%) create mode 100644 src/services/transformers.spec.ts rename src/{factories/transform.ts => services/transformers.ts} (92%) create mode 100644 src/testing/cases.ts rename src/testing/{jest.ts => globals.ts} (71%) diff --git a/README.md b/README.md index 2212e51..b952951 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ Custom Jest matchers are exposed for testing validators: ```json { - "setupFilesAfterEnv": ["reviewed/dist/testing/jest.js"] + "setupFilesAfterEnv": ["reviewed/dist/testing/globals.js"] } ``` @@ -192,7 +192,7 @@ Custom Jest matchers are exposed for testing validators: ```json { - "files": ["node_modules/reviewed/dist/testing/jest.d.ts"] + "files": ["node_modules/reviewed/dist/testing/globals.d.ts"] } ``` @@ -209,20 +209,6 @@ describe("isNaturalNumberString", () => { }); ``` -For convenience you can define whole suites at once: - -```ts -import { isNaturalNumberString } from "./strings"; -import { suite } from "reviewed"; - -suite(isNaturalNumberString, [{ input: "1", parsed: 1 }], { - "Not a string": [undefined, null, true, 1, [], {}], - "Not a number string": ["", "true", "a", "NaN", "Infinity"], - "Not an integer string": ["0.5"], - "Not a natural number string": ["0", "-1"], -}); -``` - ### Combining validators Validators can be chained to validate a payload: diff --git a/jest.config.json b/jest.config.json index c1888a5..1e1bad4 100644 --- a/jest.config.json +++ b/jest.config.json @@ -1,9 +1,20 @@ { "transform": { - "^.+\\.ts$": ["ts-jest", { "isolatedModules": true }] + "^.+\\.ts$": [ + "ts-jest", + { + "isolatedModules": true, + "tsconfig": { + "sourceMap": true + } + } + ] }, "modulePathIgnorePatterns": ["dist"], - "setupFilesAfterEnv": ["/src/testing/jest.ts"], + "setupFilesAfterEnv": ["/src/testing/globals.ts"], "collectCoverage": true, - "coveragePathIgnorePatterns": ["/src/testing"] + "coveragePathIgnorePatterns": [ + "/src/internal", + "/src/testing" + ] } diff --git a/src/factories/arrays.ts b/src/factories/arrays.ts index a5e7816..3da6d6b 100644 --- a/src/factories/arrays.ts +++ b/src/factories/arrays.ts @@ -1,5 +1,5 @@ import { Validated, Validator } from "../models/validators"; -import { all } from "./results"; +import { all } from "../services/results"; import { isArray } from "../validators/arrays"; /** diff --git a/src/factories/fallbacks.spec.ts b/src/factories/fallbacks.spec.ts index ac84c22..7e173b5 100644 --- a/src/factories/fallbacks.spec.ts +++ b/src/factories/fallbacks.spec.ts @@ -1,5 +1,5 @@ import { isNumber } from "../validators/primitives"; -import { validateEachOr, validateOr } from "./fallbacks"; +import { validateEachOr, validateOr, validatedOr } from "./fallbacks"; describe("validateOr", () => { it("validates an input with a fallback", () => { @@ -8,6 +8,13 @@ describe("validateOr", () => { }); }); +describe("validatedOr", () => { + it("provide a fallback for a validation result", () => { + expect(validatedOr(isNumber(1), 0)).toBe(1); + expect(validatedOr(isNumber("1"), 0)).toBe(0); + }); +}); + describe("validateEachOr", () => { it("validates an array of inputs with a fallback", () => { expect(validateEachOr(isNumber, 0)("")).toEqual([]); diff --git a/src/factories/fallbacks.ts b/src/factories/fallbacks.ts index ad55145..c448369 100644 --- a/src/factories/fallbacks.ts +++ b/src/factories/fallbacks.ts @@ -21,6 +21,12 @@ export const validateOr = return validated.valid ? validated.parsed : fallback; }; +// TODO (Joel): Add a docstring here +export const validatedOr = ( + { valid, parsed }: Validated, + fallback: T, +): T => (valid ? parsed : fallback); + /** * Validate an array of inputs with a fallback * @@ -37,9 +43,3 @@ export const validateEachOr = (validator: Validator, fallback: T) => (input: unknown): T[] => guard(isArray)(input) ? input.map(validateOr(validator, fallback)) : []; - -// TODO (Joel): Add a docstring here -export const validatedOr = ( - { valid, parsed }: Validated, - fallback: T, -): T => (valid ? parsed : fallback); diff --git a/src/factories/guards.spec.ts b/src/factories/guards.spec.ts index 3ac6bc0..96b16c3 100644 --- a/src/factories/guards.spec.ts +++ b/src/factories/guards.spec.ts @@ -13,8 +13,9 @@ describe("fromGuard", () => { const guard = (input: unknown): input is number => typeof input === "number" && !isNaN(input); - const isNumber = fromGuard(guard, "Not a number"); - expect(isNumber(1).parsed).toBe(1); - expect(isNumber("").error).toBe('Not a number: ""'); + const validator = fromGuard(guard, "Not a number"); + + expect(validator).toValidate(1); + expect(validator).toInvalidateWith("", 'Not a number: ""'); }); }); diff --git a/src/factories/records.spec.ts b/src/factories/records.spec.ts index 170f5c5..6886941 100644 --- a/src/factories/records.spec.ts +++ b/src/factories/records.spec.ts @@ -1,157 +1,66 @@ import { isMapping, validateWith, validateWithAtLeast } from "./records"; -import { isNaturalNumberString, isNumberString } from "../validators/strings"; -import { isNumber, isString } from "../validators/primitives"; -import { optional } from "./transform"; +import { isNumber } from "../validators/primitives"; +import { optional } from "../services/transformers"; describe("validateWith", () => { - it("validates an input's fields with validators", () => { - expect( - validateWith({ - a: isNumber, - b: isNumberString, - }), - ).toValidateAs({ a: 1, b: "2" }, { a: 1, b: 2 }); - - expect( - validateWith({ - a: isNumber, - b: isString, - }), - ).toInvalidateWith("", 'Not a record: ""'); - - expect( - validateWith({ - a: isNumber, - b: isString, - }), - ).toInvalidateWith({ a: 1, b: 2 }, { b: "Not a string: 2" }); + const validator = validateWith({ + a: isNumber, }); - it("requires exactly the specified fields", () => { - expect( - validateWith({ - a: isNumber, - b: isString, - }), - ).toInvalidateWith({ a: 1 }, "Missing required fields: b"); - - expect( - validateWith({ - a: isNumber, - b: isString, - }), - ).toInvalidateWith({ a: 1, b: "2", c: null }, "Unexpected extra fields: c"); + it("validates an input's fields with validators", () => { + expect(validator).toValidate({ a: 1 }); + expect(validator).toInvalidateWith(null, "Not a record: null"); + expect(validator).toInvalidateWith({}, "Missing required fields: a"); + expect(validator).toInvalidateWith({ a: "1" }, { a: 'Not a number: "1"' }); }); - it("parses optional fields", () => { - const validator = validateWith<{ a: string; b?: number }>({ - a: isString, - b: optional(isNaturalNumberString), - }); - - expect(validator).toValidateAs({ a: "1" }, { a: "1" }); - expect( - Object.keys( - validator({ - a: "1", - }).parsed, - ), - ).toEqual(["a"]); - - expect(validator).toValidateAs({ a: "1", b: undefined }, { a: "1" }); - expect( - Object.keys( - validator({ - a: "1", - b: undefined, - }).parsed, - ), - ).toEqual(["a"]); - + it("doesn't allow extra fields", () => { expect(validator).toInvalidateWith( - { a: "1", b: 2 }, - { b: "Not a string: 2" }, + { a: 1, b: 2 }, + "Unexpected extra fields: b", ); - - expect(validator).toValidateAs({ a: "1", b: "2" }, { a: "1", b: 2 }); }); }); describe("validateWithAtLeast", () => { it("validates an input's fields with validators", () => { - expect( - validateWithAtLeast({ - a: isNumber, - b: isString, - }), - ).toValidateAs({ a: 1, b: "2" }, { a: 1, b: "2" }); - - expect( - validateWithAtLeast({ - a: isNumber, - b: isString, - }), - ).toInvalidateWith("", 'Not a record: ""'); + const validator = validateWithAtLeast({ + a: isNumber, + }); - expect( - validateWithAtLeast({ - a: isNumber, - b: isString, - }), - ).toInvalidateWith({ a: 1, b: 2 }, { b: "Not a string: 2" }); + expect(validator).toValidate({ a: 1 }); + expect(validator).toInvalidateWith(null, "Not a record: null"); + expect(validator).toInvalidateWith({}, "Missing required fields: a"); + expect(validator).toInvalidateWith({ a: "1" }, { a: 'Not a number: "1"' }); }); it("allows extra fields", () => { - expect( - validateWithAtLeast({ - a: isNumber, - b: isString, - }), - ).toInvalidateWith({ a: 1 }, "Missing required fields: b"); + const validator = validateWithAtLeast({ + a: isNumber, + }); - expect( - validateWithAtLeast({ - a: isNumber, - b: isString, - }), - ).toValidateAs({ a: 1, b: "2", c: null }, { a: 1, b: "2", c: null }); + expect(validator).toValidate({ a: 1, b: 2 }); }); it("parses optional fields", () => { - const validator = validateWithAtLeast<{ a: string; b?: number }>({ - a: isString, - b: optional(isNaturalNumberString), + const validator = validateWithAtLeast({ + a: optional(isNumber), }); - expect(validator).toValidateAs({ a: "1" }, { a: "1" }); - expect( - Object.keys( - validator({ - a: "1", - }).parsed, - ), - ).toEqual(["a"]); + expect(validator).toValidate({ a: undefined }); + expect(validator).toValidate({}); + }); +}); - expect(validator).toValidateAs({ a: "1", b: undefined }, { a: "1" }); - expect( - Object.keys( - validator({ - a: "1", - b: undefined, - }).parsed, - ), - ).toEqual(["a"]); +describe("isMapping", () => { + const validator = isMapping(isNumber); + it("validates a record with string keys", () => { + expect(validator).toValidate({ a: 1, b: 2 }); + expect(validator).toInvalidateWith(null, "Not a record: null"); expect(validator).toInvalidateWith( - { a: "1", b: 2 }, - { b: "Not a string: 2" }, + { a: 1, b: "2" }, + { b: 'Not a number: "2"' }, ); - - expect(validator).toValidateAs({ a: "1", b: "2" }, { a: "1", b: 2 }); }); }); - -describe("isMapping", () => { - expect(isMapping(isString)).toValidate({ a: "1", b: "2" }); - expect(isMapping(isString)).toInvalidate({ a: "1", b: 2 }); -}); diff --git a/src/factories/records.ts b/src/factories/records.ts index 36987c2..8a0defd 100644 --- a/src/factories/records.ts +++ b/src/factories/records.ts @@ -6,7 +6,7 @@ import { invalidate } from "./invalidate"; import { isRecord } from "../validators/records"; import { isString } from "../validators/primitives"; import { isUndefined } from "../validators/primitives"; -import { merge } from "./results"; +import { merge } from "../services/results"; import { validate } from "./validate"; /** diff --git a/src/factories/results.spec.ts b/src/factories/results.spec.ts deleted file mode 100644 index 311a122..0000000 --- a/src/factories/results.spec.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { all, any, assert, merge, sieve } from "./results"; -import { invalidate } from "./invalidate"; -import { isBoolean, isNumber } from "../validators/primitives"; -import { mapRecord } from "../internal/records"; - -describe("all", () => { - it("merges an array of validated results using a logical AND", () => { - expect(all([]).parsed).toEqual([]); - expect(all([1, 2, 3].map(isNumber)).parsed).toEqual([1, 2, 3]); - }); - - it("invalidates only the invalidated results of an array", () => { - expect(all(["1", 2, "3"].map(isNumber)).error).toEqual([ - 'Not a number: "1"', - 'Not a number: "3"', - ]); - }); - - it("collects nested failure messages", () => { - expect( - all([ - invalidate("", ["a", "b"]), - invalidate("", [{ a: "b" }, { c: "d" }]), - ]).error, - ).toEqual([ - ["a", "b"], - [{ a: "b" }, { c: "d" }], - ]); - }); -}); - -describe("any", () => { - it("merges an array of validated results using a logical OR", () => { - expect(any(["1", 2, "3"].map(isNumber)).parsed).toEqual([2]); - }); - - it("invalidates an empty array", () => { - expect(any([]).error).toBe("Not a non empty array: []"); - }); - - it("invalidates an entirely invalidated array", () => { - expect(any(["1", "2", "3"].map(isNumber)).error).toEqual([ - 'Not a number: "1"', - 'Not a number: "2"', - 'Not a number: "3"', - ]); - }); - - it("collects nested failure messages", () => { - expect( - any([ - invalidate("", ["a", "b"]), - invalidate("", [{ a: "b" }, { c: "d" }]), - ]).error, - ).toEqual([ - ["a", "b"], - [{ a: "b" }, { c: "d" }], - ]); - }); -}); - -describe("merge", () => { - it("merges the validated fields of an object", () => { - expect(merge(mapRecord(isNumber, { a: 1, b: 2, c: 3 })).parsed).toEqual({ - a: 1, - b: 2, - c: 3, - }); - }); - - it("invalidates only the invalidated fields of an object", () => { - expect(merge(mapRecord(isNumber, { a: "1", b: 2, c: "3" })).error).toEqual({ - a: 'Not a number: "1"', - c: 'Not a number: "3"', - }); - }); -}); - -describe("sieve", () => { - it("selects parsed results from validated fields", () => { - expect(sieve(mapRecord(isNumber, { a: "1", b: 2, c: "3" }))).toEqual({ - b: 2, - }); - }); -}); - -describe("assert", () => { - it("validates an input and throws an error on failure", () => { - expect(assert(isBoolean, true)).toBe(true); - expect(() => assert(isBoolean, 0)).toThrow("Not a boolean: 0"); - }); -}); diff --git a/src/factories/transform.spec.ts b/src/factories/transform.spec.ts deleted file mode 100644 index 161a5a7..0000000 --- a/src/factories/transform.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { both, either, not, optional } from "./transform"; -import { isNaturalNumberString } from "../validators/strings"; -import { isNonEmptyArray, isStringArray } from "../validators/arrays"; -import { isNull, isString } from "../validators/primitives"; - -describe("not", () => { - it("inverts a validator", () => { - const isNotNull = not(isNull); - - expect(isNotNull("").valid).toBe(true); - expect(isNotNull("").parsed).toBe(""); - - expect(isNotNull(null).valid).toBe(false); - expect(isNotNull(null).error).toBe("Not Invalid: null"); - }); - - it("inverts a validator with a failure message", () => { - const isNotNull = not(isNull, "Is null"); - - expect(isNotNull(null).valid).toBe(false); - expect(isNotNull(null).error).toBe("Is null: null"); - }); -}); - -describe("both", () => { - it("combines two validators with a logical AND", () => { - const isNonEmptyStringArray = both(isNonEmptyArray, isStringArray); - - expect(isNonEmptyStringArray([""]).valid).toBe(true); - expect(isNonEmptyStringArray([""]).parsed).toEqual([""]); - - expect(isNonEmptyStringArray([]).valid).toBe(false); - expect(isNonEmptyStringArray([]).error).toBe("Not a non empty array: []"); - - expect(isNonEmptyStringArray([1]).valid).toBe(false); - expect(isNonEmptyStringArray([1]).error).toBe( - "Not an array of strings: [1]", - ); - }); -}); - -describe("either", () => { - it("combines two validators with a logical OR", () => { - const isStringOrNull = either(isString, isNull); - - expect(isStringOrNull("").valid).toBe(true); - expect(isStringOrNull("").parsed).toBe(""); - - expect(isStringOrNull(null).valid).toBe(true); - expect(isStringOrNull(null).parsed).toBe(null); - - expect(isStringOrNull(1).valid).toBe(false); - expect(isStringOrNull(1).error).toBe("Not a string: 1"); - }); -}); - -describe("optional", () => { - it("allows a validator to accept undefined inputs", () => { - expect(optional(isNaturalNumberString)(1).valid).toBe(false); - - expect(optional(isNaturalNumberString)("1").valid).toBe(true); - expect(optional(isNaturalNumberString)("1").parsed).toBe(1); - - expect(optional(isNaturalNumberString)(undefined).valid).toBe(true); - expect(optional(isNaturalNumberString)(undefined).parsed).toBe(undefined); - }); -}); diff --git a/src/index.ts b/src/index.ts index 80e0891..f593aeb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,73 +1,25 @@ -export { - Annotated, - Legend, - Mapping, - Optional, - RecordLike, -} from "./models/records"; -export { ArrayFields, ValidatedFields, ValidatorFields } from "./models/fields"; -export { - Guard, - Invalid, - Valid, - Validated, - ValidationErrors, - Validator, -} from "./models/validators"; -export { IJSON } from "./models/json"; -export { Listed } from "./models/arrays"; -export { RegexResult, RegexValidator } from "./models/regexes"; -export { - all, - any, - assert, - asserts, - merge, - sieve, - when, -} from "./factories/results"; -export { both, chain, either, not, optional } from "./factories/transform"; -export { fail } from "./factories/errors"; -export { filterValid } from "./factories/filter"; -export { fromGuard, guard } from "./factories/guards"; -export { invalidate, invalidateWith } from "./factories/invalidate"; -export { - isArray, - isManyOf, - isNonEmptyArray, - isNumberArray, - isOneOf, - isStringArray, -} from "./validators/arrays"; -export { isArrayOf, validateAll, validateEach } from "./factories/arrays"; -export { - isBoolean, - isNull, - isNumber, - isObject, - isString, - isUndefined, -} from "./validators/primitives"; -export { - isBooleanString, - isIntegerString, - isJSONString, - isNaturalNumberString, - isNumberString, -} from "./validators/strings"; -export { isEmail } from "./validators/regexes"; -export { isInteger, isNaturalNumber } from "./validators/numbers"; -export { - isLegend, - isMapping, - isRecordOf, - isRecordOfAtLeast, - validateWith, - validateWithAtLeast, -} from "./factories/records"; -export { isNonEmptyRecord, isRecord } from "./validators/records"; -export { serialize } from "./services/strings"; -export { validate } from "./factories/validate"; -export { validateEachOr, validateOr, validatedOr } from "./factories/fallbacks"; -export { validateIf } from "./factories/conditionals"; -export { validateRegex } from "./factories/regexes"; +export * from "./factories/arrays"; +export * from "./factories/conditionals"; +export * from "./factories/errors"; +export * from "./factories/fallbacks"; +export * from "./factories/filter"; +export * from "./factories/guards"; +export * from "./factories/invalidate"; +export * from "./factories/records"; +export * from "./factories/regexes"; +export * from "./factories/validate"; +export * from "./models/arrays"; +export * from "./models/fields"; +export * from "./models/json"; +export * from "./models/records"; +export * from "./models/regexes"; +export * from "./models/validators"; +export * from "./services/results"; +export * from "./services/strings"; +export * from "./services/transformers"; +export * from "./validators/arrays"; +export * from "./validators/numbers"; +export * from "./validators/primitives"; +export * from "./validators/records"; +export * from "./validators/regexes"; +export * from "./validators/strings"; diff --git a/src/internal/records.ts b/src/internal/records.ts index 9f07c9b..8084517 100644 --- a/src/internal/records.ts +++ b/src/internal/records.ts @@ -1,3 +1,7 @@ +/** @internal */ +export const shallowEqual = (a: unknown, b: unknown) => + JSON.stringify(a) === JSON.stringify(b); + /** @internal */ export const mapRecord = ( map: (x: U) => V, diff --git a/src/services/results.spec.ts b/src/services/results.spec.ts new file mode 100644 index 0000000..1c23a7d --- /dev/null +++ b/src/services/results.spec.ts @@ -0,0 +1,105 @@ +import { all, any, assert, merge, sieve } from "./results"; +import { invalidate } from "../factories/invalidate"; +import { isNumber } from "../validators/primitives"; +import { mapRecord } from "../internal/records"; + +describe("assert", () => { + it("validates an input and throws an error on failure", () => { + expect(assert(isNumber, 1)).toBe(1); + expect(() => assert(isNumber, null)).toThrow("Not a number: null"); + }); +}); + +describe("all", () => { + it("merges an array of validated results using a logical AND", () => { + expect(all).toValidate([]); + expect(all).toValidateAs([1, 2, 3].map(isNumber), [1, 2, 3]); + }); + + it("invalidates only the invalidated results of an array", () => { + expect(all).toInvalidateWith(["1", 2, "3"].map(isNumber), [ + 'Not a number: "1"', + 'Not a number: "3"', + ]); + }); + + it("collects nested failure messages", () => { + expect(all).toInvalidateWith( + [invalidate("", ["a", "b"]), invalidate("", [{ a: "b" }, { c: "d" }])], + [ + ["a", "b"], + [{ a: "b" }, { c: "d" }], + ], + ); + }); +}); + +describe("any", () => { + it("merges an array of validated results using a logical OR", () => { + expect(any).toValidateAs(["1", 2, "3"].map(isNumber), [2]); + }); + + it("invalidates an empty array", () => { + expect(any).toInvalidateWith([], "Not a non empty array: []"); + }); + + it("invalidates an entirely invalidated array", () => { + expect(any).toInvalidateWith(["1", "2", "3"].map(isNumber), [ + 'Not a number: "1"', + 'Not a number: "2"', + 'Not a number: "3"', + ]); + }); + + it("collects nested failure messages", () => { + expect(any).toInvalidateWith( + [invalidate("", ["a", "b"]), invalidate("", [{ a: "b" }, { c: "d" }])], + [ + ["a", "b"], + [{ a: "b" }, { c: "d" }], + ], + ); + }); +}); + +describe("merge", () => { + it("merges the validated fields of an object", () => { + const numbers = { + a: 1, + b: 2, + c: 3, + }; + + expect(merge).toValidateAs(mapRecord(isNumber, numbers), numbers); + }); + + it("invalidates only the invalidated fields of an object", () => { + expect(merge).toInvalidateWith( + mapRecord(isNumber, { + a: "1", + b: 2, + c: "3", + }), + { + a: 'Not a number: "1"', + c: 'Not a number: "3"', + }, + ); + }); +}); + +describe("sieve", () => { + it("selects parsed results from validated fields", () => { + expect( + sieve( + mapRecord(isNumber, { + a: "1", + b: 2, + c: "3", + }), + ), + ).toEqual({ + b: 2, + }); + }); +}); diff --git a/src/factories/results.ts b/src/services/results.ts similarity index 92% rename from src/factories/results.ts rename to src/services/results.ts index 7209c17..2ee3f03 100644 --- a/src/factories/results.ts +++ b/src/services/results.ts @@ -1,11 +1,22 @@ import { Validated, ValidationErrors, Validator } from "../models/validators"; import { ValidatedFields } from "../models/fields"; import { all as allPasses } from "passes"; -import { fail } from "./errors"; +import { fail } from "../factories/errors"; import { group } from "../internal/arrays"; -import { invalidate, invalidateWith } from "./invalidate"; +import { invalidate, invalidateWith } from "../factories/invalidate"; import { mapRecord, pickField, reduceRecord } from "../internal/records"; -import { validate } from "./validate"; +import { validate } from "../factories/validate"; + +// TODO (Joel): Add a docstring here +export const assert = (validator: Validator, input: unknown): T => { + const { valid, parsed, error } = validator(input); + + if (valid) { + return parsed; + } + + throw fail(error); +}; /** * Merge an array of validated results using a logical AND @@ -123,23 +134,6 @@ export const sieve = (results: ValidatedFields): Partial => results, ) as Partial; -// TODO (Joel): Add a docstring here -export const assert = (validator: Validator, input: unknown): T => { - const { valid, parsed, error } = validator(input); - - if (valid) { - return parsed; - } - - throw fail(error); -}; - -// TODO (Joel): Add a docstring here -export const asserts = - (validator: Validator) => - (input: unknown): T => - assert(validator, input); - // TODO (Joel): Add a docstring here export const when = (validator: Validator, callback: (t: T) => void) => diff --git a/src/services/strings.spec.ts b/src/services/strings.spec.ts index 156c992..bea9670 100644 --- a/src/services/strings.spec.ts +++ b/src/services/strings.spec.ts @@ -14,11 +14,9 @@ describe("serialize", () => { }); it("handles circular structures", () => { - const circular: Record = { - a: 1, - }; - + const circular: Record = { a: 1 }; circular.b = circular; + expect(serialize(circular)).toBe('{"a": 1, "b": "[Circular ~]"}'); }); }); diff --git a/src/services/transformers.spec.ts b/src/services/transformers.spec.ts new file mode 100644 index 0000000..3bc0c71 --- /dev/null +++ b/src/services/transformers.spec.ts @@ -0,0 +1,66 @@ +import { asserts, both, either, not, optional } from "./transformers"; +import { isNaturalNumberString } from "../validators/strings"; +import { isNonEmptyArray, isStringArray } from "../validators/arrays"; +import { isNull, isNumber, isString } from "../validators/primitives"; + +describe("asserts", () => { + const validator = asserts(isNumber); + + it("augments a validator to throw an error on failure", () => { + expect(validator(1)).toBe(1); + expect(() => validator(null)).toThrow("Not a number: null"); + }); +}); + +describe("not", () => { + it("inverts a validator", () => { + const isNotNull = not(isNull); + + expect(isNotNull).toValidate(""); + expect(isNotNull).toInvalidateWith(null, "Not Invalid: null"); + }); + + it("inverts a validator with a failure message", () => { + const isNotNull = not(isNull, "Is null"); + + expect(isNotNull).toInvalidateWith(null, "Is null: null"); + }); +}); + +describe("both", () => { + it("combines two validators with a logical AND", () => { + const isNonEmptyStringArray = both(isNonEmptyArray, isStringArray); + + expect(isNonEmptyStringArray).toValidate([""]); + + expect(isNonEmptyStringArray).toInvalidateWith( + [], + "Not a non empty array: []", + ); + + expect(isNonEmptyStringArray).toInvalidateWith( + [1], + "Not an array of strings: [1]", + ); + }); +}); + +describe("either", () => { + it("combines two validators with a logical OR", () => { + const isStringOrNull = either(isString, isNull); + + expect(isStringOrNull).toValidate(""); + expect(isStringOrNull).toValidate(null); + expect(isStringOrNull).toInvalidateWith(1, "Not a string: 1"); + }); +}); + +describe("optional", () => { + it("allows a validator to accept undefined inputs", () => { + const validator = optional(isNaturalNumberString); + + expect(validator).toInvalidateWith(1, "Not a string: 1"); + expect(validator).toValidateAs("1", 1); + expect(validator).toValidateAs(undefined, undefined); + }); +}); diff --git a/src/factories/transform.ts b/src/services/transformers.ts similarity index 92% rename from src/factories/transform.ts rename to src/services/transformers.ts index f5d2e9b..050f712 100644 --- a/src/factories/transform.ts +++ b/src/services/transformers.ts @@ -1,7 +1,14 @@ import { Invalid, Valid, Validated, Validator } from "../models/validators"; -import { invalidateWith } from "./invalidate"; +import { assert } from "./results"; +import { invalidateWith } from "../factories/invalidate"; import { isUndefined } from "../validators/primitives"; -import { validate } from "./validate"; +import { validate } from "../factories/validate"; + +// TODO (Joel): Add a docstring here +export const asserts = + (validator: Validator) => + (input: unknown): T => + assert(validator, input); /** * Invert a validator diff --git a/src/testing/cases.ts b/src/testing/cases.ts new file mode 100644 index 0000000..8b8046c --- /dev/null +++ b/src/testing/cases.ts @@ -0,0 +1,15 @@ +import { Validator } from "../models/validators"; + +export const validates = ( + validator: Validator, + valid: { input: unknown; parsed: unknown }[], + invalid: { input: unknown; error: unknown }[], +) => { + it.each(valid)('validates "$input" as "$parsed"', ({ input, parsed }) => { + expect(validator).toValidateAs(input, parsed); + }); + + it.each(invalid)('invalidates "$input" with "$error"', ({ input, error }) => { + expect(validator).toInvalidateWith(input, error); + }); +}; diff --git a/src/testing/jest.ts b/src/testing/globals.ts similarity index 71% rename from src/testing/jest.ts rename to src/testing/globals.ts index e3d012b..5f8f8a1 100644 --- a/src/testing/jest.ts +++ b/src/testing/globals.ts @@ -1,6 +1,4 @@ import { - toBeInvalidatedBy, - toBeValidatedBy, toInvalidate, toInvalidateWith, toValidate, @@ -11,22 +9,16 @@ declare global { namespace jest { interface Matchers { toValidate(input: unknown): R; - toInvalidate(input: unknown): R; - toValidateAs(input: unknown, target: unknown): R; + toInvalidate(input: unknown): R; toInvalidateWith(input: unknown, target: unknown): R; - - toBeValidatedBy(validator: unknown): R; - toBeInvalidatedBy(validator: unknown): R; } } } expect.extend({ toValidate, - toInvalidate, toValidateAs, + toInvalidate, toInvalidateWith, - toBeValidatedBy, - toBeInvalidatedBy, }); diff --git a/src/testing/matchers.ts b/src/testing/matchers.ts index 106c21f..4fa3581 100644 --- a/src/testing/matchers.ts +++ b/src/testing/matchers.ts @@ -5,10 +5,7 @@ import { didNotValidate, didNotValidateAs, } from "./messages"; - -/** @internal */ -const shallowEqual = (a: unknown, b: unknown) => - JSON.stringify(a) === JSON.stringify(b); +import { shallowEqual } from "../internal/records"; /** * Assert that a validator passes a given input @@ -91,27 +88,3 @@ export const toInvalidateWith = ( : didNotInvalidateWith(input, expected, error), }; }; - -/** - * Assert that a given input is passed by a validator - * - * @category Testing - * @example - * expect("1").toBeValidatedBy(isNaturalNumberString); - * - * @param validator - The validator - */ -export const toBeValidatedBy = (input: unknown, validator: Validator) => - toValidate(validator, input); - -/** - * Assert that a given input is failed by a validator - * - * @category Testing - * @example - * expect({}).toBeInvalidatedBy(isNaturalNumberString); - * - * @param validator - The validator - */ -export const toBeInvalidatedBy = (input: unknown, validator: Validator) => - toInvalidate(validator, input); diff --git a/src/testing/messages.ts b/src/testing/messages.ts index 89d48b4..4e8bf3f 100644 --- a/src/testing/messages.ts +++ b/src/testing/messages.ts @@ -1,8 +1,6 @@ -import { serialize } from "../services/strings"; - /** @internal */ export const didNotValidate = (input: unknown, received: unknown): string => - `Expected to validate ${JSON.stringify(input)} but received: ${serialize(received)}`; + `Expected to validate ${JSON.stringify(input)} but received: ${JSON.stringify(received)}`; /** @internal */ export const didNotInvalidate = (input: unknown): string => @@ -22,4 +20,4 @@ export const didNotInvalidateWith = ( expected: unknown, received: unknown, ): string => - `Expected to invalidate ${JSON.stringify(input)} with: ${JSON.stringify(expected)} but invalidated with: ${serialize(received)}`; + `Expected to invalidate ${JSON.stringify(input)} with: ${JSON.stringify(expected)} but invalidated with: ${JSON.stringify(received)}`; diff --git a/src/validators/arrays.spec.ts b/src/validators/arrays.spec.ts index c4e372f..e585b62 100644 --- a/src/validators/arrays.spec.ts +++ b/src/validators/arrays.spec.ts @@ -1,108 +1,207 @@ import { - isManyOf, - isOneOf, isArray, + isManyOf, isNonEmptyArray, isNumberArray, + isOneOf, isStringArray, } from "./arrays"; +import { validates } from "../testing/cases"; describe("isArray", () => { - test.each([ - { input: [], parsed: [] }, - { input: [1], parsed: [1] }, - ])("validates $input as array", ({ input, parsed }) => { - expect(isArray).toValidateAs(input, parsed); - }); - - test.each([ - { input: undefined, error: 'Not an array: "undefined"' }, - { input: null, error: "Not an array: null" }, - { input: true, error: "Not an array: true" }, - { input: 1, error: "Not an array: 1" }, - { input: "a", error: 'Not an array: "a"' }, - { input: {}, error: "Not an array: {}" }, - ])('fails $input with "$error"', ({ input, error }) => { - expect(isArray).toInvalidateWith(input, error); - }); + validates( + isArray, + [ + { + input: [], + parsed: [], + }, + { + input: [1], + parsed: [1], + }, + ], + [ + { + input: undefined, + error: 'Not an array: "undefined"', + }, + { + input: null, + error: "Not an array: null", + }, + { + input: true, + error: "Not an array: true", + }, + { + input: 1, + error: "Not an array: 1", + }, + { + input: "a", + error: 'Not an array: "a"', + }, + { + input: {}, + error: "Not an array: {}", + }, + ], + ); }); describe("isNonEmptyArray", () => { - test.each([{ input: [1], parsed: [1] }])( - "validates $input as non-empty array", - ({ input, parsed }) => { - expect(isNonEmptyArray).toValidateAs(input, parsed); - }, + validates( + isNonEmptyArray, + [ + { + input: [1], + parsed: [1], + }, + ], + [ + { + input: undefined, + error: 'Not an array: "undefined"', + }, + { + input: null, + error: "Not an array: null", + }, + { + input: true, + error: "Not an array: true", + }, + { + input: 1, + error: "Not an array: 1", + }, + { + input: "a", + error: 'Not an array: "a"', + }, + { + input: {}, + error: "Not an array: {}", + }, + { + input: [], + error: "Not a non empty array: []", + }, + ], ); - - test.each([ - { input: undefined, error: 'Not an array: "undefined"' }, - { input: null, error: "Not an array: null" }, - { input: true, error: "Not an array: true" }, - { input: 1, error: "Not an array: 1" }, - { input: "a", error: 'Not an array: "a"' }, - { input: {}, error: "Not an array: {}" }, - { input: [], error: "Not a non empty array: []" }, - ])('fails $input with "$error"', ({ input, error }) => { - expect(isNonEmptyArray).toInvalidateWith(input, error); - }); }); describe("isNumberArray", () => { - test.each([ - { input: [], parsed: [] }, - { input: [1], parsed: [1] }, - ])("validates $input as number array", ({ input, parsed }) => { - expect(isNumberArray).toValidateAs(input, parsed); - }); - - test.each([ - { input: undefined, error: 'Not an array: "undefined"' }, - { input: null, error: "Not an array: null" }, - { input: true, error: "Not an array: true" }, - { input: 1, error: "Not an array: 1" }, - { input: "a", error: 'Not an array: "a"' }, - { input: {}, error: "Not an array: {}" }, - { input: ["a"], error: 'Not an array of numbers: ["a"]' }, - ])('fails $input with "$error"', ({ input, error }) => { - expect(isNumberArray).toInvalidateWith(input, error); - }); + validates( + isNumberArray, + [ + { + input: [], + parsed: [], + }, + { + input: [1], + parsed: [1], + }, + ], + [ + { + input: undefined, + error: 'Not an array: "undefined"', + }, + { + input: null, + error: "Not an array: null", + }, + { + input: true, + error: "Not an array: true", + }, + { + input: 1, + error: "Not an array: 1", + }, + { + input: "a", + error: 'Not an array: "a"', + }, + { + input: {}, + error: "Not an array: {}", + }, + { + input: ["a"], + error: 'Not an array of numbers: ["a"]', + }, + ], + ); }); describe("isStringArray", () => { - test.each([ - { input: [], parsed: [] }, - { input: ["a"], parsed: ["a"] }, - ])("validates $input as string array", ({ input, parsed }) => { - expect(isStringArray).toValidateAs(input, parsed); - }); - - test.each([ - { input: undefined, error: 'Not an array: "undefined"' }, - { input: null, error: "Not an array: null" }, - { input: true, error: "Not an array: true" }, - { input: 1, error: "Not an array: 1" }, - { input: "a", error: 'Not an array: "a"' }, - { input: {}, error: "Not an array: {}" }, - { input: [1], error: "Not an array of strings: [1]" }, - ])('fails $input with "$error"', ({ input, error }) => { - expect(isStringArray).toInvalidateWith(input, error); - }); + validates( + isStringArray, + [ + { + input: [], + parsed: [], + }, + { + input: ["a"], + parsed: ["a"], + }, + ], + [ + { + input: undefined, + error: 'Not an array: "undefined"', + }, + { + input: null, + error: "Not an array: null", + }, + { + input: true, + error: "Not an array: true", + }, + { + input: 1, + error: "Not an array: 1", + }, + { + input: "a", + error: 'Not an array: "a"', + }, + { + input: {}, + error: "Not an array: {}", + }, + { + input: [1], + error: "Not an array of strings: [1]", + }, + ], + ); }); describe("isOneOf", () => { + const validator = isOneOf([1, 2, 3]); + it("validates if an object is a value from a set of options", () => { - expect(isOneOf([1, 2, 3])(1).parsed).toBe(1); - expect(isOneOf([1, 2, 3])(4).error).toBe("Not one of [1, 2, 3]: 4"); + expect(validator).toValidate(1); + expect(validator).toInvalidateWith(4, "Not one of [1, 2, 3]: 4"); }); }); describe("isManyOf", () => { + const validator = isManyOf([1, 2, 3]); + it("validates if an array contains only values from a set of options", () => { - expect(isManyOf([1, 2, 3])([3, 1]).parsed).toEqual([3, 1]); - expect(isManyOf([1, 2, 3])("").error).toBe('Not an array: ""'); - expect(isManyOf([1, 2, 3])([3, 1, 4]).error).toBe( - "Not an array of [1, 2, 3]: [3, 1, 4]", + expect(validator).toValidate([1]); + expect(validator).toInvalidateWith("", 'Not an array: ""'); + expect(validator).toInvalidateWith( + [1, 2, 4], + "Not an array of [1, 2, 3]: [1, 2, 4]", ); }); }); diff --git a/src/validators/numbers.spec.ts b/src/validators/numbers.spec.ts index d65d0d6..5879f49 100644 --- a/src/validators/numbers.spec.ts +++ b/src/validators/numbers.spec.ts @@ -1,52 +1,126 @@ import { isInteger, isNaturalNumber } from "./numbers"; +import { validates } from "../testing/cases"; describe("isInteger", () => { - test.each([ - { input: -1, parsed: -1 }, - { input: 0, parsed: 0 }, - { input: 1, parsed: 1 }, - ])("validates $input as $parsed", ({ input, parsed }) => { - expect(isInteger).toValidateAs(input, parsed); - }); - - test.each([ - { input: undefined, error: 'Not a number: "undefined"' }, - { input: null, error: "Not a number: null" }, - { input: true, error: "Not a number: true" }, - { input: "", error: 'Not a number: ""' }, - { input: "a", error: 'Not a number: "a"' }, - { input: [], error: "Not a number: []" }, - { input: {}, error: "Not a number: {}" }, - { input: NaN, error: 'Not a number: "NaN"' }, - { input: Infinity, error: 'Not a number: "Infinity"' }, - { input: 0.5, error: "Not an integer: 0.5" }, - ])('fails $input with "$error"', ({ input, error }) => { - expect(isInteger).toInvalidateWith(input, error); - }); + validates( + isInteger, + [ + { + input: -1, + parsed: -1, + }, + { + input: 0, + parsed: 0, + }, + { + input: 1, + parsed: 1, + }, + ], + [ + { + input: undefined, + error: 'Not a number: "undefined"', + }, + { + input: null, + error: "Not a number: null", + }, + { + input: true, + error: "Not a number: true", + }, + { + input: "", + error: 'Not a number: ""', + }, + { + input: "a", + error: 'Not a number: "a"', + }, + { + input: [], + error: "Not a number: []", + }, + { + input: {}, + error: "Not a number: {}", + }, + { + input: NaN, + error: 'Not a number: "NaN"', + }, + { + input: Infinity, + error: 'Not a number: "Infinity"', + }, + { + input: 0.5, + error: "Not an integer: 0.5", + }, + ], + ); }); describe("isNaturalNumber", () => { - test.each([{ input: 1, parsed: 1 }])( - "validates $input as $parsed", - ({ input, parsed }) => { - expect(isNaturalNumber).toValidateAs(input, parsed); - }, + validates( + isNaturalNumber, + [ + { + input: 1, + parsed: 1, + }, + ], + [ + { + input: undefined, + error: 'Not a number: "undefined"', + }, + { + input: null, + error: "Not a number: null", + }, + { + input: true, + error: "Not a number: true", + }, + { + input: "", + error: 'Not a number: ""', + }, + { + input: "a", + error: 'Not a number: "a"', + }, + { + input: [], + error: "Not a number: []", + }, + { + input: {}, + error: "Not a number: {}", + }, + { + input: NaN, + error: 'Not a number: "NaN"', + }, + { + input: Infinity, + error: 'Not a number: "Infinity"', + }, + { + input: 0.5, + error: "Not an integer: 0.5", + }, + { + input: -1, + error: "Not a natural number: -1", + }, + { + input: 0, + error: "Not a natural number: 0", + }, + ], ); - - test.each([ - { input: undefined, error: 'Not a number: "undefined"' }, - { input: null, error: "Not a number: null" }, - { input: true, error: "Not a number: true" }, - { input: "", error: 'Not a number: ""' }, - { input: "a", error: 'Not a number: "a"' }, - { input: [], error: "Not a number: []" }, - { input: {}, error: "Not a number: {}" }, - { input: NaN, error: 'Not a number: "NaN"' }, - { input: Infinity, error: 'Not a number: "Infinity"' }, - { input: 0.5, error: "Not an integer: 0.5" }, - { input: -1, error: "Not a natural number: -1" }, - { input: 0, error: "Not a natural number: 0" }, - ])('fails $input with "$error"', ({ input, error }) => { - expect(isNaturalNumber).toInvalidateWith(input, error); - }); }); diff --git a/src/validators/primitives.spec.ts b/src/validators/primitives.spec.ts index 1cd9a02..169cde9 100644 --- a/src/validators/primitives.spec.ts +++ b/src/validators/primitives.spec.ts @@ -1,134 +1,277 @@ import { - isUndefined, - isNull, isBoolean, + isNull, isNumber, - isString, isObject, + isString, + isUndefined, } from "./primitives"; +import { validates } from "../testing/cases"; describe("isUndefined", () => { - test.each([{ input: undefined, parsed: undefined }])( - "validates $input as $parsed", - ({ input, parsed }) => { - expect(isUndefined).toValidateAs(input, parsed); - }, + validates( + isUndefined, + [ + { + input: undefined, + parsed: undefined, + }, + ], + [ + { + input: null, + error: "Not undefined: null", + }, + { + input: true, + error: "Not undefined: true", + }, + { + input: 1, + error: "Not undefined: 1", + }, + { + input: "", + error: 'Not undefined: ""', + }, + { + input: "a", + error: 'Not undefined: "a"', + }, + { + input: [], + error: "Not undefined: []", + }, + { + input: {}, + error: "Not undefined: {}", + }, + ], ); - - test.each([ - { input: null, error: "Not undefined: null" }, - { input: true, error: "Not undefined: true" }, - { input: 1, error: "Not undefined: 1" }, - { input: "", error: 'Not undefined: ""' }, - { input: "a", error: 'Not undefined: "a"' }, - { input: [], error: "Not undefined: []" }, - { input: {}, error: "Not undefined: {}" }, - ])('fails $input with "$error"', ({ input, error }) => { - expect(isUndefined).toInvalidateWith(input, error); - }); }); describe("isNull", () => { - test.each([{ input: null, parsed: null }])( - "validates $input as $parsed", - ({ input, parsed }) => { - expect(isNull).toValidateAs(input, parsed); - }, + validates( + isNull, + [ + { + input: null, + parsed: null, + }, + ], + [ + { + input: undefined, + error: 'Not null: "undefined"', + }, + { + input: true, + error: "Not null: true", + }, + { + input: 1, + error: "Not null: 1", + }, + { + input: "", + error: 'Not null: ""', + }, + { + input: "a", + error: 'Not null: "a"', + }, + { + input: [], + error: "Not null: []", + }, + { + input: {}, + error: "Not null: {}", + }, + ], ); - - test.each([ - { input: undefined, error: 'Not null: "undefined"' }, - { input: true, error: "Not null: true" }, - { input: 1, error: "Not null: 1" }, - { input: "", error: 'Not null: ""' }, - { input: "a", error: 'Not null: "a"' }, - { input: [], error: "Not null: []" }, - { input: {}, error: "Not null: {}" }, - ])('fails $input with "$error"', ({ input, error }) => { - expect(isNull).toInvalidateWith(input, error); - }); }); describe("isBoolean", () => { - test.each([ - { input: true, parsed: true }, - { input: false, parsed: false }, - ])("validates $input as $parsed", ({ input, parsed }) => { - expect(isBoolean).toValidateAs(input, parsed); - }); - - test.each([ - { input: undefined, error: 'Not a boolean: "undefined"' }, - { input: null, error: "Not a boolean: null" }, - { input: 1, error: "Not a boolean: 1" }, - { input: "", error: 'Not a boolean: ""' }, - { input: "a", error: 'Not a boolean: "a"' }, - { input: [], error: "Not a boolean: []" }, - { input: {}, error: "Not a boolean: {}" }, - ])('fails $input with "$error"', ({ input, error }) => { - expect(isBoolean).toInvalidateWith(input, error); - }); + validates( + isBoolean, + [ + { + input: true, + parsed: true, + }, + { + input: false, + parsed: false, + }, + ], + [ + { + input: undefined, + error: 'Not a boolean: "undefined"', + }, + { + input: null, + error: "Not a boolean: null", + }, + { + input: 1, + error: "Not a boolean: 1", + }, + { + input: "", + error: 'Not a boolean: ""', + }, + { + input: "a", + error: 'Not a boolean: "a"', + }, + { + input: [], + error: "Not a boolean: []", + }, + { + input: {}, + error: "Not a boolean: {}", + }, + ], + ); }); describe("isNumber", () => { - test.each([ - { input: 0, parsed: 0 }, - { input: 1, parsed: 1 }, - ])("validates $input as $parsed", ({ input, parsed }) => { - expect(isNumber).toValidateAs(input, parsed); - }); - - test.each([ - { input: undefined, error: 'Not a number: "undefined"' }, - { input: null, error: "Not a number: null" }, - { input: true, error: "Not a number: true" }, - { input: NaN, error: 'Not a number: "NaN"' }, - { input: Infinity, error: 'Not a number: "Infinity"' }, - { input: "", error: 'Not a number: ""' }, - { input: "a", error: 'Not a number: "a"' }, - { input: [], error: "Not a number: []" }, - { input: {}, error: "Not a number: {}" }, - ])('fails $input with "$error"', ({ input, error }) => { - expect(isNumber).toInvalidateWith(input, error); - }); + validates( + isNumber, + [ + { + input: 0, + parsed: 0, + }, + { + input: 1, + parsed: 1, + }, + ], + [ + { + input: undefined, + error: 'Not a number: "undefined"', + }, + { + input: null, + error: "Not a number: null", + }, + { + input: true, + error: "Not a number: true", + }, + { + input: NaN, + error: 'Not a number: "NaN"', + }, + { + input: Infinity, + error: 'Not a number: "Infinity"', + }, + { + input: "", + error: 'Not a number: ""', + }, + { + input: "a", + error: 'Not a number: "a"', + }, + { + input: [], + error: "Not a number: []", + }, + { + input: {}, + error: "Not a number: {}", + }, + ], + ); }); describe("isString", () => { - test.each([ - { input: "", parsed: "" }, - { input: "a", parsed: "a" }, - ])("validates $input as $parsed", ({ input, parsed }) => { - expect(isString).toValidateAs(input, parsed); - }); - - test.each([ - { input: undefined, error: 'Not a string: "undefined"' }, - { input: null, error: "Not a string: null" }, - { input: true, error: "Not a string: true" }, - { input: 1, error: "Not a string: 1" }, - { input: [], error: "Not a string: []" }, - { input: {}, error: "Not a string: {}" }, - ])('fails $input with "$error"', ({ input, error }) => { - expect(isString).toInvalidateWith(input, error); - }); + validates( + isString, + [ + { + input: "", + parsed: "", + }, + { + input: "a", + parsed: "a", + }, + ], + [ + { + input: undefined, + error: 'Not a string: "undefined"', + }, + { + input: null, + error: "Not a string: null", + }, + { + input: true, + error: "Not a string: true", + }, + { + input: 1, + error: "Not a string: 1", + }, + { + input: [], + error: "Not a string: []", + }, + { + input: {}, + error: "Not a string: {}", + }, + ], + ); }); describe("isObject", () => { - test.each([ - { input: [], parsed: [] }, - { input: { a: 1 }, parsed: { a: 1 } }, - ])("validates $input as $parsed", ({ input, parsed }) => { - expect(isObject).toValidateAs(input, parsed); - }); - - test.each([ - { input: undefined, error: 'Not an object: "undefined"' }, - { input: null, error: "Not an object: null" }, - { input: true, error: "Not an object: true" }, - { input: 1, error: "Not an object: 1" }, - { input: NaN, error: 'Not an object: "NaN"' }, - { input: Infinity, error: 'Not an object: "Infinity"' }, - ])('fails $input with "$error"', ({ input, error }) => { - expect(isObject).toInvalidateWith(input, error); - }); + validates( + isObject, + [ + { + input: [], + parsed: [], + }, + { + input: { a: 1 }, + parsed: { a: 1 }, + }, + ], + [ + { + input: undefined, + error: 'Not an object: "undefined"', + }, + { + input: null, + error: "Not an object: null", + }, + { + input: true, + error: "Not an object: true", + }, + { + input: 1, + error: "Not an object: 1", + }, + { + input: NaN, + error: 'Not an object: "NaN"', + }, + { + input: Infinity, + error: 'Not an object: "Infinity"', + }, + ], + ); }); diff --git a/src/validators/records.spec.ts b/src/validators/records.spec.ts index a245610..d72422e 100644 --- a/src/validators/records.spec.ts +++ b/src/validators/records.spec.ts @@ -1,45 +1,102 @@ -import { isRecord, isNonEmptyRecord } from "./records"; +import { isNonEmptyRecord, isRecord } from "./records"; +import { validates } from "../testing/cases"; describe("isRecord", () => { - test.each([ - { input: {}, parsed: {} }, - { input: { a: 1 }, parsed: { a: 1 } }, - { input: { a: "a" }, parsed: { a: "a" } }, - { input: { 1: "a" }, parsed: { 1: "a" } }, - ])("validates $input as record", ({ input, parsed }) => { - expect(isRecord).toValidateAs(input, parsed); - }); - - test.each([ - { input: undefined, error: 'Not a record: "undefined"' }, - { input: null, error: "Not a record: null" }, - { input: true, error: "Not a record: true" }, - { input: 1, error: "Not a record: 1" }, - { input: "a", error: 'Not a record: "a"' }, - { input: [], error: "Not a record: []" }, - ])('fails $input with "$error"', ({ input, error }) => { - expect(isRecord).toInvalidateWith(input, error); - }); + validates( + isRecord, + [ + { + input: {}, + parsed: {}, + }, + { + input: { a: 1 }, + parsed: { a: 1 }, + }, + { + input: { a: "a" }, + parsed: { a: "a" }, + }, + { + input: { 1: "a" }, + parsed: { 1: "a" }, + }, + ], + [ + { + input: undefined, + error: 'Not a record: "undefined"', + }, + { + input: null, + error: "Not a record: null", + }, + { + input: true, + error: "Not a record: true", + }, + { + input: 1, + error: "Not a record: 1", + }, + { + input: "a", + error: 'Not a record: "a"', + }, + { + input: [], + error: "Not a record: []", + }, + ], + ); }); describe("isNonEmptyRecord", () => { - test.each([ - { input: { a: 1 }, parsed: { a: 1 } }, - { input: { a: "a" }, parsed: { a: "a" } }, - { input: { 1: "a" }, parsed: { 1: "a" } }, - ])("validates $input as non-empty record", ({ input, parsed }) => { - expect(isNonEmptyRecord).toValidateAs(input, parsed); - }); - - test.each([ - { input: undefined, error: 'Not a record: "undefined"' }, - { input: null, error: "Not a record: null" }, - { input: true, error: "Not a record: true" }, - { input: 1, error: "Not a record: 1" }, - { input: "a", error: 'Not a record: "a"' }, - { input: [], error: "Not a record: []" }, - { input: {}, error: "Not a non empty record: {}" }, - ])('fails $input with "$error"', ({ input, error }) => { - expect(isNonEmptyRecord).toInvalidateWith(input, error); - }); + validates( + isNonEmptyRecord, + [ + { + input: { a: 1 }, + parsed: { a: 1 }, + }, + { + input: { a: "a" }, + parsed: { a: "a" }, + }, + { + input: { 1: "a" }, + parsed: { 1: "a" }, + }, + ], + [ + { + input: undefined, + error: 'Not a record: "undefined"', + }, + { + input: null, + error: "Not a record: null", + }, + { + input: true, + error: "Not a record: true", + }, + { + input: 1, + error: "Not a record: 1", + }, + { + input: "a", + error: 'Not a record: "a"', + }, + { + input: [], + error: "Not a record: []", + }, + { + input: {}, + error: "Not a non empty record: {}", + }, + ], + ); }); diff --git a/src/validators/regexes.spec.ts b/src/validators/regexes.spec.ts index 6cf8da6..10d2a32 100644 --- a/src/validators/regexes.spec.ts +++ b/src/validators/regexes.spec.ts @@ -1,45 +1,59 @@ import { isEmail } from "./regexes"; +import { validates } from "../testing/cases"; describe("isEmail", () => { - test.each([ - { - input: "user@domain.com", - parsed: { - match: "user@domain.com", - index: 0, - captured: ["user", "domain.com"], - named: { user: "user", domain: "domain.com" }, - }, - }, - { - input: "user.name@domain.com", - parsed: { - match: "user.name@domain.com", - index: 0, - captured: ["user.name", "domain.com"], - named: { user: "user.name", domain: "domain.com" }, - }, - }, - { - input: "user@sub.domain.com", - parsed: { - match: "user@sub.domain.com", - index: 0, - captured: ["user", "sub.domain.com"], - named: { user: "user", domain: "sub.domain.com" }, - }, - }, - ])("validates $input as email", ({ input, parsed }) => { - expect(isEmail).toValidateAs(input, parsed); - }); - - test.each([ - { input: "user", error: 'Not an email: "user"' }, - { input: "@domain.com", error: 'Not an email: "@domain.com"' }, - { input: "user@domain", error: 'Not an email: "user@domain"' }, - { input: "user-domain.com", error: 'Not an email: "user-domain.com"' }, - { input: "user~@domain.com", error: 'Not an email: "user~@domain.com"' }, - ])('fails $input with "$error"', ({ input, error }) => { - expect(isEmail).toInvalidateWith(input, error); - }); + validates( + isEmail, + [ + { + input: "user@domain.com", + parsed: { + match: "user@domain.com", + index: 0, + captured: ["user", "domain.com"], + named: { user: "user", domain: "domain.com" }, + }, + }, + { + input: "user.name@domain.com", + parsed: { + match: "user.name@domain.com", + index: 0, + captured: ["user.name", "domain.com"], + named: { user: "user.name", domain: "domain.com" }, + }, + }, + { + input: "user@sub.domain.com", + parsed: { + match: "user@sub.domain.com", + index: 0, + captured: ["user", "sub.domain.com"], + named: { user: "user", domain: "sub.domain.com" }, + }, + }, + ], + [ + { + input: "user", + error: 'Not an email: "user"', + }, + { + input: "@domain.com", + error: 'Not an email: "@domain.com"', + }, + { + input: "user@domain", + error: 'Not an email: "user@domain"', + }, + { + input: "user-domain.com", + error: 'Not an email: "user-domain.com"', + }, + { + input: "user~@domain.com", + error: 'Not an email: "user~@domain.com"', + }, + ], + ); }); diff --git a/src/validators/strings.spec.ts b/src/validators/strings.spec.ts index 920385d..ed11183 100644 --- a/src/validators/strings.spec.ts +++ b/src/validators/strings.spec.ts @@ -1,125 +1,282 @@ import { isBooleanString, - isNumberString, isIntegerString, - isNaturalNumberString, isJSONString, + isNaturalNumberString, + isNumberString, } from "./strings"; +import { validates } from "../testing/cases"; describe("isBooleanString", () => { - test.each([ - { input: "true", parsed: true }, - { input: "false", parsed: false }, - ])("validates $input as $parsed", ({ input, parsed }) => { - expect(isBooleanString).toValidateAs(input, parsed); - }); - - test.each([ - { input: undefined, error: 'Not a string: "undefined"' }, - { input: null, error: "Not a string: null" }, - { input: true, error: "Not a string: true" }, - { input: 1, error: "Not a string: 1" }, - { input: [], error: "Not a string: []" }, - { input: {}, error: "Not a string: {}" }, - ])('fails $input with "$error"', ({ input, error }) => { - expect(isBooleanString).toInvalidateWith(input, error); - }); + validates( + isBooleanString, + [ + { + input: "true", + parsed: true, + }, + { + input: "false", + parsed: false, + }, + ], + [ + { + input: undefined, + error: 'Not a string: "undefined"', + }, + { + input: null, + error: "Not a string: null", + }, + { + input: true, + error: "Not a string: true", + }, + { + input: 1, + error: "Not a string: 1", + }, + { + input: [], + error: "Not a string: []", + }, + { + input: {}, + error: "Not a string: {}", + }, + ], + ); }); describe("isNumberString", () => { - test.each([ - { input: "-1", parsed: -1 }, - { input: "0", parsed: 0 }, - { input: "0.5", parsed: 0.5 }, - { input: "1", parsed: 1 }, - ])("validates $input as $parsed", ({ input, parsed }) => { - expect(isNumberString).toValidateAs(input, parsed); - }); - - test.each([ - { input: undefined, error: 'Not a string: "undefined"' }, - { input: null, error: "Not a string: null" }, - { input: true, error: "Not a string: true" }, - { input: 1, error: "Not a string: 1" }, - { input: [], error: "Not a string: []" }, - { input: {}, error: "Not a string: {}" }, - { input: "", error: 'Not a number string: ""' }, - { input: "true", error: 'Not a number string: "true"' }, - { input: "a", error: 'Not a number string: "a"' }, - { input: "NaN", error: 'Not a number string: "NaN"' }, - { input: "Infinity", error: 'Not a number string: "Infinity"' }, - ])('fails $input with "$error"', ({ input, error }) => { - expect(isNumberString).toInvalidateWith(input, error); - }); + validates( + isNumberString, + [ + { + input: "-1", + parsed: -1, + }, + { + input: "0", + parsed: 0, + }, + { + input: "0.5", + parsed: 0.5, + }, + { + input: "1", + parsed: 1, + }, + ], + [ + { + input: undefined, + error: 'Not a string: "undefined"', + }, + { + input: null, + error: "Not a string: null", + }, + { + input: true, + error: "Not a string: true", + }, + { + input: 1, + error: "Not a string: 1", + }, + { + input: [], + error: "Not a string: []", + }, + { + input: {}, + error: "Not a string: {}", + }, + { + input: "", + error: 'Not a number string: ""', + }, + { + input: "true", + error: 'Not a number string: "true"', + }, + { + input: "a", + error: 'Not a number string: "a"', + }, + { + input: "NaN", + error: 'Not a number string: "NaN"', + }, + { + input: "Infinity", + error: 'Not a number string: "Infinity"', + }, + ], + ); }); describe("isIntegerString", () => { - test.each([ - { input: "-1", parsed: -1 }, - { input: "0", parsed: 0 }, - { input: "1", parsed: 1 }, - ])("validates $input as $parsed", ({ input, parsed }) => { - expect(isIntegerString).toValidateAs(input, parsed); - }); - - test.each([ - { input: undefined, error: 'Not a string: "undefined"' }, - { input: null, error: "Not a string: null" }, - { input: true, error: "Not a string: true" }, - { input: 1, error: "Not a string: 1" }, - { input: [], error: "Not a string: []" }, - { input: {}, error: "Not a string: {}" }, - { input: "", error: 'Not a number string: ""' }, - { input: "true", error: 'Not a number string: "true"' }, - { input: "a", error: 'Not a number string: "a"' }, - { input: "NaN", error: 'Not a number string: "NaN"' }, - { input: "Infinity", error: 'Not a number string: "Infinity"' }, - { input: "0.5", error: 'Not an integer string: "0.5"' }, - ])('fails $input with "$error"', ({ input, error }) => { - expect(isIntegerString).toInvalidateWith(input, error); - }); + validates( + isIntegerString, + [ + { + input: "-1", + parsed: -1, + }, + { + input: "0", + parsed: 0, + }, + { + input: "1", + parsed: 1, + }, + ], + [ + { + input: undefined, + error: 'Not a string: "undefined"', + }, + { + input: null, + error: "Not a string: null", + }, + { + input: true, + error: "Not a string: true", + }, + { + input: 1, + error: "Not a string: 1", + }, + { + input: [], + error: "Not a string: []", + }, + { + input: {}, + error: "Not a string: {}", + }, + { + input: "", + error: 'Not a number string: ""', + }, + { + input: "true", + error: 'Not a number string: "true"', + }, + { + input: "a", + error: 'Not a number string: "a"', + }, + { + input: "NaN", + error: 'Not a number string: "NaN"', + }, + { + input: "Infinity", + error: 'Not a number string: "Infinity"', + }, + { + input: "0.5", + error: 'Not an integer string: "0.5"', + }, + ], + ); }); describe("isNaturalNumberString", () => { - test.each([{ input: "1", parsed: 1 }])( - "validates $input as $parsed", - ({ input, parsed }) => { - expect(isNaturalNumberString).toValidateAs(input, parsed); - }, + validates( + isNaturalNumberString, + [ + { + input: "1", + parsed: 1, + }, + ], + [ + { + input: undefined, + error: 'Not a string: "undefined"', + }, + { + input: null, + error: "Not a string: null", + }, + { + input: true, + error: "Not a string: true", + }, + { + input: 1, + error: "Not a string: 1", + }, + { + input: [], + error: "Not a string: []", + }, + { + input: {}, + error: "Not a string: {}", + }, + { + input: "", + error: 'Not a number string: ""', + }, + { + input: "true", + error: 'Not a number string: "true"', + }, + { + input: "a", + error: 'Not a number string: "a"', + }, + { + input: "NaN", + error: 'Not a number string: "NaN"', + }, + { + input: "Infinity", + error: 'Not a number string: "Infinity"', + }, + { + input: "0.5", + error: 'Not an integer string: "0.5"', + }, + { + input: "0", + error: 'Not a natural number string: "0"', + }, + { + input: "-1", + error: 'Not a natural number string: "-1"', + }, + ], ); - - test.each([ - { input: undefined, error: 'Not a string: "undefined"' }, - { input: null, error: "Not a string: null" }, - { input: true, error: "Not a string: true" }, - { input: 1, error: "Not a string: 1" }, - { input: [], error: "Not a string: []" }, - { input: {}, error: "Not a string: {}" }, - { input: "", error: 'Not a number string: ""' }, - { input: "true", error: 'Not a number string: "true"' }, - { input: "a", error: 'Not a number string: "a"' }, - { input: "NaN", error: 'Not a number string: "NaN"' }, - { input: "Infinity", error: 'Not a number string: "Infinity"' }, - { input: "0.5", error: 'Not an integer string: "0.5"' }, - { input: "0", error: 'Not a natural number string: "0"' }, - { input: "-1", error: 'Not a natural number string: "-1"' }, - ])('fails $input with "$error"', ({ input, error }) => { - expect(isNaturalNumberString).toInvalidateWith(input, error); - }); }); describe("isJSONString", () => { - test.each([{ input: '{"a": 1}', parsed: { a: 1 } }])( - "validates $input as $parsed", - ({ input, parsed }) => { - expect(isJSONString).toValidateAs(input, parsed); - }, - ); - - test.each([{ input: "_", error: 'Not JSON: "_"' }])( - 'fails $input with "$error"', - ({ input, error }) => { - expect(isJSONString).toInvalidateWith(input, error); - }, + validates( + isJSONString, + [ + { + input: '{"a": 1}', + parsed: { a: 1 }, + }, + ], + [ + { + input: null, + error: "Not a string: null", + }, + { + input: "_", + error: 'Not JSON: "_"', + }, + ], ); }); From dcb8b9754eb36c23abc44f055c8db9b5dc9df03c Mon Sep 17 00:00:00 2001 From: JoelLefkowitz Date: Wed, 4 Mar 2026 12:02:00 +0000 Subject: [PATCH 3/5] Add tests for when and chain --- src/factories/guards.spec.ts | 1 + src/factories/records.spec.ts | 27 +++++++++++++-------------- src/services/results.spec.ts | 16 +++++++++++++++- src/services/transformers.spec.ts | 20 +++++++++++++++----- 4 files changed, 44 insertions(+), 20 deletions(-) diff --git a/src/factories/guards.spec.ts b/src/factories/guards.spec.ts index 96b16c3..0e627c0 100644 --- a/src/factories/guards.spec.ts +++ b/src/factories/guards.spec.ts @@ -4,6 +4,7 @@ import { isNumber } from "../validators/primitives"; describe("guard", () => { it("constructs a guard from a validator", () => { const isNumberGuard = guard(isNumber); + expect(isNumberGuard(1)).toBe(true); }); }); diff --git a/src/factories/records.spec.ts b/src/factories/records.spec.ts index 6886941..a81be37 100644 --- a/src/factories/records.spec.ts +++ b/src/factories/records.spec.ts @@ -3,23 +3,29 @@ import { isNumber } from "../validators/primitives"; import { optional } from "../services/transformers"; describe("validateWith", () => { - const validator = validateWith({ - a: isNumber, - }); - it("validates an input's fields with validators", () => { + const validator = validateWith({ + a: isNumber, + }); + expect(validator).toValidate({ a: 1 }); expect(validator).toInvalidateWith(null, "Not a record: null"); expect(validator).toInvalidateWith({}, "Missing required fields: a"); expect(validator).toInvalidateWith({ a: "1" }, { a: 'Not a number: "1"' }); - }); - - it("doesn't allow extra fields", () => { expect(validator).toInvalidateWith( { a: 1, b: 2 }, "Unexpected extra fields: b", ); }); + + it("parses optional fields", () => { + const validator = validateWith({ + a: optional(isNumber), + }); + + expect(validator).toValidate({ a: undefined }); + expect(validator).toValidate({}); + }); }); describe("validateWithAtLeast", () => { @@ -32,13 +38,6 @@ describe("validateWithAtLeast", () => { expect(validator).toInvalidateWith(null, "Not a record: null"); expect(validator).toInvalidateWith({}, "Missing required fields: a"); expect(validator).toInvalidateWith({ a: "1" }, { a: 'Not a number: "1"' }); - }); - - it("allows extra fields", () => { - const validator = validateWithAtLeast({ - a: isNumber, - }); - expect(validator).toValidate({ a: 1, b: 2 }); }); diff --git a/src/services/results.spec.ts b/src/services/results.spec.ts index 1c23a7d..8d138da 100644 --- a/src/services/results.spec.ts +++ b/src/services/results.spec.ts @@ -1,4 +1,4 @@ -import { all, any, assert, merge, sieve } from "./results"; +import { all, any, assert, merge, sieve, when } from "./results"; import { invalidate } from "../factories/invalidate"; import { isNumber } from "../validators/primitives"; import { mapRecord } from "../internal/records"; @@ -103,3 +103,17 @@ describe("sieve", () => { }); }); }); + +describe("when", () => { + it("calls a callback when validation succeeds", () => { + const numbers = []; + + const effect = when(isNumber, (x) => { + numbers.push(x); + }); + + [1, "2", 3].forEach(effect); + + expect(numbers).toEqual([1, 3]); + }); +}); diff --git a/src/services/transformers.spec.ts b/src/services/transformers.spec.ts index 3bc0c71..249e32a 100644 --- a/src/services/transformers.spec.ts +++ b/src/services/transformers.spec.ts @@ -1,14 +1,14 @@ -import { asserts, both, either, not, optional } from "./transformers"; +import { asserts, both, chain, either, not, optional } from "./transformers"; import { isNaturalNumberString } from "../validators/strings"; -import { isNonEmptyArray, isStringArray } from "../validators/arrays"; +import { isNonEmptyArray, isOneOf, isStringArray } from "../validators/arrays"; import { isNull, isNumber, isString } from "../validators/primitives"; describe("asserts", () => { - const validator = asserts(isNumber); + const assertor = asserts(isNumber); it("augments a validator to throw an error on failure", () => { - expect(validator(1)).toBe(1); - expect(() => validator(null)).toThrow("Not a number: null"); + expect(assertor(1)).toBe(1); + expect(() => assertor(null)).toThrow("Not a number: null"); }); }); @@ -64,3 +64,13 @@ describe("optional", () => { expect(validator).toValidateAs(undefined, undefined); }); }); + +describe("chain", () => { + it("combines validators sequentially", () => { + const validator = chain(isOneOf([1, 2]), isOneOf([2, 3])); + + expect(validator).toValidate(2); + expect(validator).toInvalidateWith(1, "Not one of [2, 3]: 1"); + expect(validator).toInvalidateWith(3, "Not one of [1, 2]: 3"); + }); +}); From d83f8bc730c16f05b18407810488224bf02bb74c Mon Sep 17 00:00:00 2001 From: JoelLefkowitz Date: Wed, 4 Mar 2026 12:47:47 +0000 Subject: [PATCH 4/5] Add docstrings --- README.md | 5 ++- src/factories/records.ts | 23 ++++++++-- src/index.ts | 4 +- src/{factories => services}/fallbacks.spec.ts | 0 src/{factories => services}/fallbacks.ts | 19 ++++++-- src/{factories => services}/filter.spec.ts | 0 src/{factories => services}/filter.ts | 4 +- src/services/results.ts | 33 +++++++++++--- src/services/transformers.ts | 45 ++++++++++++++++--- 9 files changed, 107 insertions(+), 26 deletions(-) rename src/{factories => services}/fallbacks.spec.ts (100%) rename src/{factories => services}/fallbacks.ts (75%) rename src/{factories => services}/filter.spec.ts (100%) rename src/{factories => services}/filter.ts (89%) diff --git a/README.md b/README.md index b952951..1da5d48 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,10 @@ const parse = (input: unknown) => { I want to validate an object and get failure messages for each field: ```ts -import { errors, isNaturalNumberString } from "reviewed"; +import { errors, isRecordOf, isNaturalNumberString } from "reviewed"; const paginate = (url: URL): void => { - const isPagination = validateWith({ + const isPagination = isRecordOf({ page: isNaturalNumberString, size: isNaturalNumberString, }); @@ -43,6 +43,7 @@ const paginate = (url: URL): void => { }); if (valid) { + // Parsed type: { page: number, size: number } console.log(parsed); } else { console.error(error); diff --git a/src/factories/records.ts b/src/factories/records.ts index 8a0defd..329efa9 100644 --- a/src/factories/records.ts +++ b/src/factories/records.ts @@ -108,7 +108,6 @@ export const validateWithAtLeast = return merge(Object.fromEntries(validated) as ValidatedFields); }; -// TODO (Joel): Improve the README.md intro for reviewed showing isRecordOf() and the inferred type /** * Alias for validateWith * @@ -125,7 +124,20 @@ export const isRecordOf = validateWith; */ export const isRecordOfAtLeast = validateWithAtLeast; -// TODO (Joel): Add a docstring here +/** + * Validate a record with string keys + * + * @category Services + * @example + * isMapping(isNumber)({ a: 1 }) >> + * { + * valid: true, + * input: { a: 1 }, + * }; + * + * @typeParam T - The validated type + * @param validator - The validator to use + */ export const isMapping = (validator: Validator): Validator> => (input: unknown) => { @@ -143,5 +155,10 @@ export const isMapping = return merge(Object.fromEntries(validated) as ValidatedFields>); }; -// TODO (Joel): Add a docstring here +/** + * Alias for isMapping(isString) + * + * @category Aliases + * @see {@link isMapping} + */ export const isLegend: Validator = isMapping(isString); diff --git a/src/index.ts b/src/index.ts index f593aeb..eedfc37 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,8 @@ export * from "./factories/arrays"; export * from "./factories/conditionals"; export * from "./factories/errors"; -export * from "./factories/fallbacks"; -export * from "./factories/filter"; +export * from "./services/fallbacks"; +export * from "./services/filter"; export * from "./factories/guards"; export * from "./factories/invalidate"; export * from "./factories/records"; diff --git a/src/factories/fallbacks.spec.ts b/src/services/fallbacks.spec.ts similarity index 100% rename from src/factories/fallbacks.spec.ts rename to src/services/fallbacks.spec.ts diff --git a/src/factories/fallbacks.ts b/src/services/fallbacks.ts similarity index 75% rename from src/factories/fallbacks.ts rename to src/services/fallbacks.ts index c448369..ab5aebb 100644 --- a/src/factories/fallbacks.ts +++ b/src/services/fallbacks.ts @@ -1,11 +1,11 @@ import { Validated, Validator } from "../models/validators"; -import { guard } from "./guards"; +import { guard } from "../factories/guards"; import { isArray } from "../validators/arrays"; /** * Validate an input with a fallback * - * @category Factories + * @category Services * @example * validateOr(isNumber, 0)(1) >> 1; * validateOr(isNumber, 0)("1") >> 0; @@ -21,7 +21,18 @@ export const validateOr = return validated.valid ? validated.parsed : fallback; }; -// TODO (Joel): Add a docstring here +/** + * Provide a fallback to a validated result + * + * @category Services + * @example + * validatedOr(isNumber(1), 0) >> 1; + * validatedOr(isNumber("1"), 0) >> 0; + * + * @typeParam T - The validated type + * @param result - The validated result + * @param fallback - The fallback + */ export const validatedOr = ( { valid, parsed }: Validated, fallback: T, @@ -30,7 +41,7 @@ export const validatedOr = ( /** * Validate an array of inputs with a fallback * - * @category Factories + * @category Services * @example * validateEachOr(isNumber, 0)([1, 2, 3]) >> [1, 2, 3]; * validateEachOr(isNumber, 0)(["1", 2, "3"]) >> [0, 2, 0]; diff --git a/src/factories/filter.spec.ts b/src/services/filter.spec.ts similarity index 100% rename from src/factories/filter.spec.ts rename to src/services/filter.spec.ts diff --git a/src/factories/filter.ts b/src/services/filter.ts similarity index 89% rename from src/factories/filter.ts rename to src/services/filter.ts index 3e50679..6e23b41 100644 --- a/src/factories/filter.ts +++ b/src/services/filter.ts @@ -1,11 +1,11 @@ import { Validator } from "../models/validators"; -import { guard } from "./guards"; +import { guard } from "../factories/guards"; import { isArray } from "../validators/arrays"; /** * Filter an array of inputs with a validator * - * @category Factories + * @category Services * @example * filterValid(isNumber)(["1", 2, "3"]) >> [2]; * diff --git a/src/services/results.ts b/src/services/results.ts index 2ee3f03..546d765 100644 --- a/src/services/results.ts +++ b/src/services/results.ts @@ -7,7 +7,18 @@ import { invalidate, invalidateWith } from "../factories/invalidate"; import { mapRecord, pickField, reduceRecord } from "../internal/records"; import { validate } from "../factories/validate"; -// TODO (Joel): Add a docstring here +/** + * Validate an input and throws an error on failure + * + * @category Services + * @example + * assert(isNumber, null) >> + * throws: "Not a number: null" + * + * @typeParam T - The validated type + * @param validator - The validator to use + * @param input - The raw input + */ export const assert = (validator: Validator, input: unknown): T => { const { valid, parsed, error } = validator(input); @@ -21,7 +32,7 @@ export const assert = (validator: Validator, input: unknown): T => { /** * Merge an array of validated results using a logical AND * - * @category Factories + * @category Services * @example * all(["1", 2, "3"].map(isNumber)) >> * { @@ -50,7 +61,7 @@ export const all = (results: Validated[]): Validated => { /** * Merge an array of validated results using a logical OR * - * @category Factories + * @category Services * @example * any(["1", 2, "3"].map(isNumber)) >> * { @@ -84,7 +95,7 @@ export const any = (results: Validated[]): Validated => { /** * Merge the validated fields of an object * - * @category Factories + * @category Services * @example * merge({ a: isNumber("1"), b: isNumber(2), c: isNumber("3") }) >> * { @@ -119,7 +130,7 @@ export const merge = (results: ValidatedFields): Validated => { /** * Select parsed results from validated fields * - * @category Factories + * @category Services * @example * sieve({ a: isNumber("1"), b: isNumber(2), c: isNumber("3") }) >> * { b: 2 }; @@ -134,7 +145,17 @@ export const sieve = (results: ValidatedFields): Partial => results, ) as Partial; -// TODO (Joel): Add a docstring here +/** + * Call a callback when validation succeeds + * + * @category Services + * @example + * when(isNumber, () => {...}) + * + * @typeParam T - The validated type + * @param validator - The validator to use + * @param callback - The callback to call + */ export const when = (validator: Validator, callback: (t: T) => void) => (input: unknown): void => { diff --git a/src/services/transformers.ts b/src/services/transformers.ts index 050f712..826a1d7 100644 --- a/src/services/transformers.ts +++ b/src/services/transformers.ts @@ -4,7 +4,17 @@ import { invalidateWith } from "../factories/invalidate"; import { isUndefined } from "../validators/primitives"; import { validate } from "../factories/validate"; -// TODO (Joel): Add a docstring here +/** + * Augment a validator to throw an error on failure + * + * @category Services + * @example + * asserts(isNumber)(null) >> + * throws: "Not a number: null" + * + * @typeParam T - The validated type + * @param validator - The validator to use + */ export const asserts = (validator: Validator) => (input: unknown): T => @@ -13,7 +23,7 @@ export const asserts = /** * Invert a validator * - * @category Factories + * @category Services * @example * const isNotNull = not(isNull, "Is null"); * @@ -39,7 +49,7 @@ export const not = /** * Combine two validators with a logical AND * - * @category Factories + * @category Services * @example * const isNonEmptyStringArray = both(isNonEmptyArray, isStringArray); * @@ -82,7 +92,7 @@ export const both = /** * Combine two validators with a logical OR * - * @category Factories + * @category Services * @example * const isStringOrNull = either(isString, isNull); * @@ -126,13 +136,13 @@ export const either = /** * Allow a validator to accept undefined inputs * - * @category Factories + * @category Services * @example * interface Person { * name?: string; * } * - * const isPerson = isRecordOf({ name: optional(isString) }); + * const isPerson = validateWith({ name: optional(isString) }); * * isPerson({}) >> * { @@ -153,7 +163,28 @@ export const optional = ( validator: Validator, ): Validator => either(validator, isUndefined); -// TODO (Joel): Add a docstring here +/** + * Combine validators sequentially + * + * @category Services + * @example + * const select = chain(isOneOf([1, 2]), isOneOf([2, 3])); + * + * select(2) >> + * { + * valid: true, + * parsed: 2, + * }; + * + * select(1) >> + * { + * valid: false, + * error: "Not one of [2, 3]: 1", + * }; + * + * @typeParam T - The validated type + * @param validator - The validator to use + */ export const chain = (first: Validator, second: Validator) => (input: unknown): Invalid | Invalid | Valid => { From 6829446cd4f5fd5cd7d13d4d1888b65a55aa0361 Mon Sep 17 00:00:00 2001 From: JoelLefkowitz Date: Wed, 4 Mar 2026 12:49:18 +0000 Subject: [PATCH 5/5] Add spec spacing --- src/factories/guards.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/factories/guards.spec.ts b/src/factories/guards.spec.ts index 0e627c0..b3d1ed0 100644 --- a/src/factories/guards.spec.ts +++ b/src/factories/guards.spec.ts @@ -4,7 +4,7 @@ import { isNumber } from "../validators/primitives"; describe("guard", () => { it("constructs a guard from a validator", () => { const isNumberGuard = guard(isNumber); - + expect(isNumberGuard(1)).toBe(true); }); });