diff --git a/src/lib/Shapes.ts b/src/lib/Shapes.ts index 601325c0..6aa8705f 100644 --- a/src/lib/Shapes.ts +++ b/src/lib/Shapes.ts @@ -20,6 +20,7 @@ import { TupleValidator, UnionValidator } from '../validators/imports'; +import { LazyValidator } from '../validators/LazyValidator'; import { NativeEnumLike, NativeEnumValidator } from '../validators/NativeEnumValidator'; import { TypedArrayValidator } from '../validators/TypedArrayValidator'; import type { Constructor, MappedObjectValidator } from './util-types'; @@ -161,4 +162,8 @@ export class Shapes { public map(keyValidator: BaseValidator, valueValidator: BaseValidator) { return new MapValidator(keyValidator, valueValidator); } + + public lazy>(validator: (value: unknown) => T) { + return new LazyValidator(validator); + } } diff --git a/src/validators/LazyValidator.ts b/src/validators/LazyValidator.ts new file mode 100644 index 00000000..aa470f66 --- /dev/null +++ b/src/validators/LazyValidator.ts @@ -0,0 +1,20 @@ +import type { Result } from '../lib/Result'; +import type { IConstraint, Unwrap } from '../type-exports'; +import { BaseValidator, ValidatorError } from './imports'; + +export class LazyValidator, R = Unwrap> extends BaseValidator { + private readonly validator: (value: unknown) => T; + + public constructor(validator: (value: unknown) => T, constraints: readonly IConstraint[] = []) { + super(constraints); + this.validator = validator; + } + + protected override clone(): this { + return Reflect.construct(this.constructor, [this.validator, this.constraints]); + } + + protected handle(values: unknown): Result { + return this.validator(values).run(values) as Result; + } +} diff --git a/tests/validators/lazy.test.ts b/tests/validators/lazy.test.ts new file mode 100644 index 00000000..9e485f4f --- /dev/null +++ b/tests/validators/lazy.test.ts @@ -0,0 +1,75 @@ +import { CombinedPropertyError, ExpectedConstraintError, MissingPropertyError, s, SchemaOf, ValidationError } from '../../src'; +import { expectError } from '../common/macros/comparators'; + +describe('LazyValidator', () => { + const predicate = s.lazy((value) => { + if (typeof value === 'boolean') return s.boolean.true; + return s.string; + }); + + test.each([true, 'hello'])('GIVEN %j THEN returns the given value', (input) => { + expect(predicate.parse(input)).toBe(input); + }); + + test('GIVEN an invalid value THEN throw ValidationError', () => { + expectError(() => predicate.parse(123), new ValidationError('s.string', 'Expected a string primitive', 123)); + }); +}); + +describe('NestedLazyValidator', () => { + const predicate = s.lazy((value) => { + if (typeof value === 'boolean') return s.boolean.true; + return s.lazy((value) => { + if (typeof value === 'string') return s.string.lengthEqual(5); + return s.number; + }); + }); + + test.each([true, 'hello', 123])('GIVEN %j THEN returns the given value', (input) => { + expect(predicate.parse(input)).toBe(input); + }); + + test('GIVEN an invalid value THEN throw ValidationError', () => { + expectError( + () => predicate.parse('Sapphire'), + new ExpectedConstraintError('s.string.lengthEqual', 'Invalid string length', 'Sapphire', 'expected.length === 5') + ); + }); +}); + +describe('CircularLazyValidator', () => { + interface PredicateSchema { + id: string; + items: PredicateSchema; + } + + const predicate: SchemaOf = s.object({ + id: s.string, + items: s.lazy>(() => predicate) + }); + + test('GIVEN circular schema THEN throw ', () => { + expectError( + () => predicate.parse({ id: 'Hello', items: { id: 'Hello', items: { id: 'Hello' } } }), + new CombinedPropertyError([ + ['items', new CombinedPropertyError([['items', new CombinedPropertyError([['items', new MissingPropertyError('items')]])]])] + ]) + ); + }); +}); + +describe('PassingCircularLazyValidator', () => { + interface PredicateSchema { + id: string; + items?: PredicateSchema; + } + + const predicate: SchemaOf = s.object({ + id: s.string, + items: s.lazy>(() => predicate).optional + }); + + test('GIVEN circular schema THEN return given value', () => { + expect(predicate.parse({ id: 'Sapphire', items: { id: 'Hello' } })).toStrictEqual({ id: 'Sapphire', items: { id: 'Hello' } }); + }); +});