diff --git a/src/arrays.ts b/src/arrays.ts index 07f04ea..47846e3 100644 --- a/src/arrays.ts +++ b/src/arrays.ts @@ -10,14 +10,7 @@ import { * Invalid validation result for an item in the array * @category Types */ -export interface ArrayItemValidatorResult { - /** The index of the item in the original array */ - index: number; - /** The error message if a simple type */ - message: string; - /** The errors if an object or array */ - errors: unknown; -} +export type ArrayItemValidatorResult = null | string | E; /** * Configuration for array validation. @@ -76,10 +69,10 @@ export function applyArrayConfig( * items. The remaining validators target the array itself. * @category Type Validators */ -export function array( - config?: Partial | ValidatorTest, +export function array( + config?: Partial | ValidatorTest, ...tests: ValidatorTest[] -): ValidatorTest { +): ValidatorTest[]> { let allTests = tests; let finalConfig: ArrayConfig = { parser: parseArray, @@ -123,11 +116,9 @@ export function array( if (result.state === 'invalid') { isValid = false; - errors.push({ - errors: result.errors, - index, - message: result.message, - }); + errors.push(result.message || result.errors); + } else { + errors.push(null); } } } @@ -139,14 +130,14 @@ export function array( }); } - return invalid({ + return invalid[]>({ message: arrayInvalidResult?.state === 'invalid' ? arrayInvalidResult.message : '', value, field, - errors, + errors: errors as ArrayItemValidatorResult[], }); }; } diff --git a/src/objects.ts b/src/objects.ts index 9c80238..50c7c22 100644 --- a/src/objects.ts +++ b/src/objects.ts @@ -1,5 +1,4 @@ import { - DeepPartial, ExtractError, ExtractValue, hasOwnProperty, @@ -14,21 +13,19 @@ export type ObjectParameter = Record>; * Validates an object. * @category Type Validators */ -export function object

( - properties: P -): ValidatorTest< - DeepPartial<{ [K in keyof P]?: ExtractValue }>, - DeepPartial<{ [K in keyof P]?: ExtractError }> -> { +export function object< + P extends ObjectParameter, + V extends { [K in keyof P]?: ExtractValue }, + E extends { [K in keyof P]?: ExtractError } +>(properties: P): ValidatorTest { if (typeof properties !== 'object' || properties === null) { throw new TypeError('`properties` must be a configuration object'); } return async (values, field) => { - const definedValues: { [K in keyof P]?: ExtractValue } = - (values as { [K in keyof P]?: ExtractValue }) ?? {}; - const errors: { [K in keyof P]?: ExtractError } = {}; - const resolvedValues: { [K in keyof P]?: ExtractValue } = {}; + const definedValues = (values ?? {}) as V; + const errors = {} as E; + const resolvedValues = {} as V; let isValid = true; for (const key in properties) { @@ -42,11 +39,14 @@ export function object

( : await invalid({ field: key, message: 'No validator set', value }); /* eslint-enable no-await-in-loop */ - resolvedValues[key] = result.value as ExtractValue; + resolvedValues[key] = result.value as V[Extract]; if (result.state === 'invalid') { isValid = false; - errors[key] = (result.message || result.errors) as ExtractError; + errors[key] = (result.message || result.errors) as E[Extract< + keyof P, + string + >]; } } } diff --git a/src/shared.ts b/src/shared.ts index bd6a0a0..f42ab51 100644 --- a/src/shared.ts +++ b/src/shared.ts @@ -137,8 +137,8 @@ export function isObject(value: unknown): boolean { /** * Safe hasOwnProperty check - * @param obj Object to check - * @param prop Property to look for + * @param object Object to check + * @param property Property to look for */ export function hasOwnProperty( object: unknown, @@ -153,7 +153,7 @@ const formatPattern = /{(\w+)}/g; /** * Formats a message using a squiggly bracket `{}` template. * @param template Message template as string or function - * @param params Params to inject into template + * @param parameters Params to inject into template * @category Helpers */ export function formatMessage>( @@ -198,7 +198,11 @@ export async function valid({ * @param extras Extra message params * @category Helpers */ -export async function invalid>({ +export async function invalid< + T, + E, + P extends ValidatorMessageParameters = ValidatorMessageParameters +>({ errors, field, message, @@ -229,10 +233,10 @@ export async function invalid>({ * @param applyConfig Function that applies configuration to value. * @category Helpers */ -export function createTypeValidatorTest, E>( +export function createTypeValidatorTest>( defaultConfig: C, applyConfig: (value: unknown, config: C) => T | null | undefined -): ValidatorFactory { +): ValidatorFactory { return (config, ...tests) => { const cache: Record> = {}; let finalConfig = defaultConfig; diff --git a/test/arrays.test.ts b/test/arrays.test.ts index fe4e350..dc65fb2 100644 --- a/test/arrays.test.ts +++ b/test/arrays.test.ts @@ -58,13 +58,7 @@ test('array', async () => { state: 'invalid', value: ['foo', 'ba'], message: '', - errors: [ - { - message: 'min:3', - index: 1, - errors: undefined, - }, - ], + errors: [null, 'min:3'], isValid: false, field: undefined, }); @@ -84,18 +78,11 @@ test('array with object', async () => { message: '', errors: [ { - index: 0, - message: '', - errors: { - username: 'required', - }, + username: 'required', }, + null, { - index: 2, - message: '', - errors: { - username: 'min:3', - }, + username: 'min:3', }, ], isValid: false, @@ -110,24 +97,7 @@ test('nested array', async () => { state: 'invalid', message: '', value: [['', 'foo', null]], - errors: [ - { - index: 0, - message: '', - errors: [ - { - index: 0, - message: 'required', - errors: undefined, - }, - { - index: 2, - message: 'required', - errors: undefined, - }, - ], - }, - ], + errors: [['required', null, 'required']], isValid: false, field: undefined, }); diff --git a/test/kitchen-sink.test.ts b/test/kitchen-sink.test.ts index fa4f30b..690b19a 100644 --- a/test/kitchen-sink.test.ts +++ b/test/kitchen-sink.test.ts @@ -6,7 +6,10 @@ import { date, email, exact, + ExtractError, integer, + invalid, + InvalidResult, matches, max, maxDate, @@ -18,9 +21,8 @@ import { required, string, url, - ValidatorTest, valid, - invalid, + ValidatorTest, } from '../src/index'; const ONE_DAY = 1000 * 60 * 60 * 24; @@ -28,7 +30,7 @@ const TEN_DAYS = ONE_DAY * 10; const now = new Date(); const tenDaysFromNow = new Date(now.getTime() + TEN_DAYS); -const customValidator: ValidatorTest = async (value, field) => { +const customValidator: ValidatorTest = async (value, field) => { if (value === 'hello') { return valid({ value, field }); } @@ -63,7 +65,7 @@ const validate = object({ ({ values }) => `Must be ${values[0]}, ${values[1]} or ${values[2]}.` ) ), - favoriteCarMakers: array( + favoriteCarMakers: array( { default: [] }, string( required('Car brand is required.'), @@ -100,7 +102,7 @@ const validate = object({ custom: customValidator, }); -test('kitchen sink', async () => { +test('kitchen sink valid', async () => { const startDate = new Date(now.getTime() + ONE_DAY); const result = await validate({ @@ -132,3 +134,49 @@ test('kitchen sink', async () => { startDate, }); }); + +test('kitchen sink invalid', async () => { + const startDate = new Date(now.getTime() - ONE_DAY); + + const result = await validate({ + age: 10, + custom: 'goodbye', + emailAddress: 'invalid-email', + favoriteCarMakers: ['Nissan', 'Volvo'], + firstName: '', + fruit: 'strawberry', + homepage: 'invalid-url', + notRobot: 10, + postalCode: '1234', + startDate, + }); + + assert.is(result.state, 'invalid'); + assert.equal(result.value, { + age: 10, + custom: 'goodbye', + emailAddress: 'invalid-email', + favoriteCarMakers: ['Nissan', 'Volvo'], + firstName: '', + fruit: 'strawberry', + homepage: 'invalid-url', + notRobot: 10, + postalCode: '1234', + startDate, + }); + const invalidResult = result as InvalidResult>; + assert.equal(invalidResult.errors, { + firstName: 'First name is required.', + emailAddress: 'Must be a valid email address.', + age: 'Must be at least 18 years old.', + homepage: 'Must be a valid URL.', + fruit: 'Must be apple, orange or banana.', + // Note that this would be a string if the error is from the array validator. + favoriteCarMakers: ['Must be a known car brand.', null], + startDate: 'Must be on or after today.', + notRobot: 'This is the answer.', + optIn: 'Must decide to opt in or out.', + postalCode: 'Must be a five-digit number.', + custom: 'Must be "hello".', + }); +});