From a8febddcfbe42358e63194ae8da582e66b746edf Mon Sep 17 00:00:00 2001 From: Jason Quense Date: Fri, 7 Jan 2022 10:48:11 -0500 Subject: [PATCH] feat: add Tuple type (#1546) * WIP * feat: add tuple type * docs, errors * support reach --- README.md | 24 ++++++ package.json | 1 - src/Lazy.ts | 12 ++- src/array.ts | 23 +++--- src/index.ts | 3 + src/locale.ts | 24 ++++++ src/object.ts | 29 +++---- src/schema.ts | 64 ++++++++++++--- src/tuple.ts | 146 +++++++++++++++++++++++++++++++++++ src/types.ts | 26 ++++++- src/util/cloneDeep.ts | 45 +++++++++++ src/util/collectErrors.ts | 3 + src/util/createValidation.ts | 6 +- src/util/reach.ts | 22 ++++-- test/mixed.ts | 10 +-- test/object.ts | 6 +- test/tuple.ts | 112 +++++++++++++++++++++++++++ test/types/types.ts | 88 ++++++++++++++++++++- test/yup.js | 26 ++++--- 19 files changed, 595 insertions(+), 75 deletions(-) create mode 100644 src/tuple.ts create mode 100644 src/util/cloneDeep.ts create mode 100644 src/util/collectErrors.ts create mode 100644 test/tuple.ts diff --git a/README.md b/README.md index f2659abe3..a3741f31d 100644 --- a/README.md +++ b/README.md @@ -1231,6 +1231,7 @@ await schema.isValid('hello'); // => true ``` By default, the `cast` logic of `string` is to call `toString` on the value if it exists. + empty values are not coerced (use `ensure()` to coerce empty values to empty strings). Failed casts return the input value. @@ -1469,6 +1470,29 @@ array() .cast(['', 1, 0, 4, false, null]); // => ['', 1, 0, 4, false] ``` +### tuple + +Tuples, are fixed length arrays where each item has a distinct type. + +Inherits from [`Schema`](#Schema). + +```js +import { tuple, string, number, InferType } from 'yup'; + +let schema = tuple([ + string().label('name'), + number().label('age').positive().integer(), +]); + +await schema.validate(['James', 3]); // ['James', 3] + +await schema.validate(['James', -24]); // => ValidationError: age must be a positive number + +InferType // [string, number] | undefined +``` + +tuples have no default casting behavior. + ### object Define an object schema. Options passed into `isValid` are also passed to child schemas. diff --git a/package.json b/package.json index beb5c1c32..448ef3277 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,6 @@ "typescript": "^4.5.4" }, "dependencies": { - "nanoclone": "^1.0.0", "property-expr": "^2.0.4", "tiny-case": "^1.0.2", "toposort": "^2.0.2" diff --git a/src/Lazy.ts b/src/Lazy.ts index 74aa2faa9..1946966ee 100644 --- a/src/Lazy.ts +++ b/src/Lazy.ts @@ -1,5 +1,10 @@ import isSchema from './util/isSchema'; -import type { AnyObject, ISchema, ValidateOptions } from './types'; +import type { + AnyObject, + ISchema, + ValidateOptions, + NestedTestConfig, +} from './types'; import type { ResolveOptions } from './Condition'; import type { @@ -82,8 +87,9 @@ class Lazy return this._resolve(value, options).cast(value, options); } - asTest(value: any, options?: ValidateOptions) { - return this._resolve(value, options).asTest(value, options); + asNestedTest(options: NestedTestConfig) { + let value = options.parent[options.index ?? options.key!]; + return this._resolve(value, options).asNestedTest(options); } validate(value: any, options?: ValidateOptions): Promise { diff --git a/src/array.ts b/src/array.ts index 675b737bf..dd9a0867b 100644 --- a/src/array.ts +++ b/src/array.ts @@ -86,7 +86,7 @@ export default class ArraySchema< options: InternalOptions = {}, panic: (err: Error, value: unknown) => void, - callback: (err: ValidationError[], value: unknown) => void, + next: (err: ValidationError[], value: unknown) => void, ) { // let sync = options.sync; // let path = options.path; @@ -99,7 +99,7 @@ export default class ArraySchema< super._validate(_value, options, panic, (arrayErrors, value) => { if (!recursive || !innerType || !this._typeCheck(value)) { - callback(arrayErrors, value); + next(arrayErrors, value); return; } @@ -107,17 +107,13 @@ export default class ArraySchema< // #950 Ensure that sparse array empty slots are validated let tests: RunTest[] = new Array(value.length); - for (let idx = 0; idx < value.length; idx++) { - let item = value[idx]; - let path = `${options.path || ''}[${idx}]`; - - tests[idx] = innerType!.asTest(item, { - ...options, - path, + for (let index = 0; index < value.length; index++) { + tests[index] = innerType!.asNestedTest({ + options, + index, parent: value, - // FIXME - index: idx, - originalValue: originalValue[idx], + parentPath: options.path, + originalParent: options.originalValue ?? _value, }); } @@ -127,8 +123,7 @@ export default class ArraySchema< tests, }, panic, - (innerTypeErrors) => - callback(innerTypeErrors.concat(arrayErrors), value), + (innerTypeErrors) => next(innerTypeErrors.concat(arrayErrors), value), ); }); } diff --git a/src/index.ts b/src/index.ts index 50d673fd9..18e5c3b2b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import NumberSchema, { create as numberCreate } from './number'; import DateSchema, { create as dateCreate } from './date'; import ObjectSchema, { AnyObject, create as objectCreate } from './object'; import ArraySchema, { create as arrayCreate } from './array'; +import TupleSchema, { create as tupleCreate } from './tuple'; import { create as refCreate } from './Reference'; import { create as lazyCreate } from './Lazy'; import ValidationError from './ValidationError'; @@ -62,6 +63,7 @@ export { arrayCreate as array, refCreate as ref, lazyCreate as lazy, + tupleCreate as tuple, reach, getIn, isSchema, @@ -79,6 +81,7 @@ export { DateSchema, ObjectSchema, ArraySchema, + TupleSchema, }; export type { diff --git a/src/locale.ts b/src/locale.ts index 791912d67..6f8a20d7b 100644 --- a/src/locale.ts +++ b/src/locale.ts @@ -1,5 +1,6 @@ import printValue from './util/printValue'; import { Message } from './types'; +import ValidationError from './ValidationError'; export interface MixedLocale { default?: Message; @@ -49,6 +50,10 @@ export interface ArrayLocale { max?: Message<{ max: number }>; } +export interface TupleLocale { + notType?: Message; +} + export interface BooleanLocale { isValue?: Message; } @@ -128,6 +133,25 @@ export let array: Required = { length: '${path} must have ${length} items', }; +export let tuple: Required = { + notType: (params) => { + const { path, value, spec } = params; + const typeLen = spec.types.length; + if (Array.isArray(value)) { + if (value.length < typeLen) + return `${path} tuple value has too few items, expected a length of ${typeLen} but got ${ + value.length + } for value: \`${printValue(value, true)}\``; + if (value.length > typeLen) + return `${path} tuple value has too many items, expected a length of ${typeLen} but got ${ + value.length + } for value: \`${printValue(value, true)}\``; + } + + return ValidationError.formatError(mixed.notType, params); + }, +}; + export default Object.assign(Object.create(null), { mixed, string, diff --git a/src/object.ts b/src/object.ts index c90b98bd6..542119f2c 100644 --- a/src/object.ts +++ b/src/object.ts @@ -198,7 +198,7 @@ export default class ObjectSchema< protected _validate( _value: any, - opts: InternalOptions = {}, + options: InternalOptions = {}, panic: (err: Error, value: unknown) => void, next: (err: ValidationError[], value: unknown) => void, ) { @@ -206,17 +206,15 @@ export default class ObjectSchema< from = [], originalValue = _value, recursive = this.spec.recursive, - } = opts; - - from = [{ schema: this, value: originalValue }, ...from]; + } = options; + options.from = [{ schema: this, value: originalValue }, ...from]; // this flag is needed for handling `strict` correctly in the context of // validation vs just casting. e.g strict() on a field is only used when validating - opts.__validating = true; - opts.originalValue = originalValue; - opts.from = from; + options.__validating = true; + options.originalValue = originalValue; - super._validate(_value, opts, panic, (objectErrors, value) => { + super._validate(_value, options, panic, (objectErrors, value) => { if (!recursive || !isObject(value)) { next(objectErrors, value); return; @@ -232,18 +230,13 @@ export default class ObjectSchema< continue; } - let path = - key.indexOf('.') === -1 - ? (opts.path ? `${opts.path}.` : '') + key - : `${opts.path || ''}["${key}"]`; - tests.push( - field.asTest(value[key], { - ...opts, - path, - from, + field.asNestedTest({ + options, + key, parent: value, - originalValue: originalValue[key], + parentPath: options.path, + originalParent: originalValue, }), ); } diff --git a/src/schema.ts b/src/schema.ts index 607b69c14..b4ccef785 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,6 +1,3 @@ -// @ts-ignore -import cloneDeep from 'nanoclone'; - import { mixed as locale } from './locale'; import Condition, { ConditionBuilder, @@ -26,6 +23,7 @@ import { ExtraParams, AnyObject, ISchema, + NestedTestConfig, } from './types'; import ValidationError from './ValidationError'; @@ -34,6 +32,7 @@ import Reference from './Reference'; import isAbsent from './util/isAbsent'; import type { Flags, Maybe, ResolveFlags, Thunk, _ } from './util/types'; import toArray from './util/toArray'; +import cloneDeep from './util/cloneDeep'; export type SchemaSpec = { coarce: boolean; @@ -50,7 +49,7 @@ export type SchemaSpec = { export type SchemaOptions = { type: string; - spec?: SchemaSpec; + spec?: Partial>; check: (value: any) => value is NonNullable; }; @@ -316,6 +315,16 @@ export default abstract class Schema< return schema; } + protected resolveOptions>(options: T): T { + return { + ...options, + from: options.from || [], + strict: options.strict ?? this.spec.strict, + abortEarly: options.abortEarly ?? this.spec.abortEarly, + recursive: options.recursive ?? this.spec.recursive, + }; + } + /** * Run the configured transform pipeline over an input value. */ @@ -336,7 +345,7 @@ export default abstract class Schema< `The value of ${ options.path || 'field' } could not be cast to a value ` + - `that satisfies the schema type: "${resolvedSchema._type}". \n\n` + + `that satisfies the schema type: "${resolvedSchema.type}". \n\n` + `attempted value: ${formattedValue} \n` + (formattedResult !== formattedValue ? `result of cast: ${formattedResult}` @@ -390,6 +399,7 @@ export default abstract class Schema< originalValue, schema: this, label: this.spec.label, + spec: this.spec, sync, from, }; @@ -470,11 +480,41 @@ export default abstract class Schema< } } - asTest(value: any, options?: ValidateOptions): RunTest { - // Nested validations fields are always strict: - // 1. parent isn't strict so the casting will also have cast inner values - // 2. parent is strict in which case the nested values weren't cast either - const testOptions = { ...options, strict: true, value }; + asNestedTest({ + key, + index, + parent, + parentPath, + originalParent, + options, + }: NestedTestConfig): RunTest { + const k = key ?? index; + if (k == null) { + throw TypeError('Must include `key` or `index` for nested validations'); + } + + const isIndex = typeof k === 'number'; + let value = parent[k]; + + const testOptions = { + ...options, + // Nested validations fields are always strict: + // 1. parent isn't strict so the casting will also have cast inner values + // 2. parent is strict in which case the nested values weren't cast either + strict: true, + parent, + value, + originalValue: originalParent[k], + // FIXME: tests depend on `index` being passed around deeply, + // we should not let the options.key/index bleed through + key: undefined, + // index: undefined, + [isIndex ? 'index' : 'key']: k, + path: + isIndex || k.includes('.') + ? `${parentPath || ''}[${value ? k : `"${k}"`}]` + : (parentPath ? `${parentPath}.` : '') + key, + }; return (_: any, panic, next) => this.resolve(testOptions)._validate(value, testOptions, panic, next); @@ -785,7 +825,7 @@ export default abstract class Schema< if (!isAbsent(value) && !this.schema._typeCheck(value)) return this.createError({ params: { - type: this.schema._type, + type: this.schema.type, }, }); return true; @@ -926,7 +966,7 @@ for (const method of ['validate', 'validateSync']) value, options.context, ); - return schema[method](parent && parent[parentPath], { + return (schema as any)[method](parent && parent[parentPath], { ...options, parent, path, diff --git a/src/tuple.ts b/src/tuple.ts new file mode 100644 index 000000000..f3a4d6984 --- /dev/null +++ b/src/tuple.ts @@ -0,0 +1,146 @@ +// @ts-ignore + +import type { AnyObject, InternalOptions, ISchema, Message } from './types'; +import type { + Defined, + Flags, + NotNull, + SetFlag, + Thunk, + ToggleDefault, + UnsetFlag, + Maybe, +} from './util/types'; +import Schema, { RunTest, SchemaSpec } from './schema'; +import ValidationError from './ValidationError'; +import { tuple as tupleLocale } from './locale'; + +type AnyTuple = [unknown, ...unknown[]]; + +export function create(schemas: { + [K in keyof T]: ISchema; +}) { + return new TupleSchema(schemas); +} + +export default interface TupleSchema< + TType extends Maybe = AnyTuple | undefined, + TContext = AnyObject, + TDefault = undefined, + TFlags extends Flags = '', +> extends Schema { + default>( + def: Thunk, + ): TupleSchema>; + + concat>(schema: TOther): TOther; + + defined( + msg?: Message, + ): TupleSchema, TContext, TDefault, TFlags>; + optional(): TupleSchema; + + required( + msg?: Message, + ): TupleSchema, TContext, TDefault, TFlags>; + notRequired(): TupleSchema, TContext, TDefault, TFlags>; + + nullable( + msg?: Message, + ): TupleSchema; + nonNullable(): TupleSchema, TContext, TDefault, TFlags>; + + strip( + enabled: false, + ): TupleSchema>; + strip( + enabled?: true, + ): TupleSchema>; +} + +interface TupleSchemaSpec extends SchemaSpec { + types: T extends any[] + ? { + [K in keyof T]: ISchema; + } + : never; +} + +export default class TupleSchema< + TType extends Maybe = AnyTuple | undefined, + TContext = AnyObject, + TDefault = undefined, + TFlags extends Flags = '', +> extends Schema { + declare spec: TupleSchemaSpec; + + constructor(schemas: [ISchema, ...ISchema[]]) { + super({ + type: 'tuple', + spec: { types: schemas } as any, + check(v: any): v is NonNullable { + const types = (this.spec as TupleSchemaSpec).types; + return Array.isArray(v) && v.length === types.length; + }, + }); + + this.withMutation(() => { + this.typeError(tupleLocale.notType); + }); + } + + protected _cast(inputValue: any, options: InternalOptions) { + const { types } = this.spec; + const value = super._cast(inputValue, options); + + if (!this._typeCheck(value)) { + return value; + } + + let isChanged = false; + const castArray = types.map((type, idx) => { + const castElement = type.cast(value[idx], { + ...options, + path: `${options.path || ''}[${idx}]`, + }); + if (castElement !== value[idx]) isChanged = true; + return castElement; + }); + + return isChanged ? castArray : value; + } + + protected _validate( + _value: any, + options: InternalOptions = {}, + panic: (err: Error, value: unknown) => void, + next: (err: ValidationError[], value: unknown) => void, + ) { + let itemTypes = this.spec.types; + + super._validate(_value, options, panic, (tupleErrors, value) => { + // intentionally not respecting recursive + if (!this._typeCheck(value)) { + next(tupleErrors, value); + return; + } + + let tests: RunTest[] = []; + for (let [index, itemSchema] of itemTypes.entries()) { + tests[index] = itemSchema!.asNestedTest({ + options, + index, + parent: value, + parentPath: options.path, + originalParent: options.originalValue ?? _value, + }); + } + + this.runTests({ value, tests }, panic, (innerTypeErrors) => + next(innerTypeErrors.concat(tupleErrors), value), + ); + }); + } +} + +create.prototype = TupleSchema.prototype; diff --git a/src/types.ts b/src/types.ts index 8c6abf295..592c93aa8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,10 @@ import type { ResolveOptions } from './Condition'; -import type { AnySchema, CastOptions, SchemaFieldDescription } from './schema'; +import type { + AnySchema, + CastOptions, + SchemaFieldDescription, + SchemaSpec, +} from './schema'; import type { Test } from './util/createValidation'; import type { AnyObject } from './util/objectTypes'; import type { Flags } from './util/types'; @@ -15,7 +20,7 @@ export interface ISchema { cast(value: any, options?: CastOptions): T; validate(value: any, options?: ValidateOptions): Promise; - asTest(value: any, options?: InternalOptions): Test; + asNestedTest(config: NestedTestConfig): Test; describe(options?: ResolveOptions): SchemaFieldDescription; resolve(options: ResolveOptions): ISchema; @@ -30,6 +35,10 @@ export type TransformFunction = ( schema: T, ) => any; +export interface Ancester { + schema: ISchema; + value: any; +} export interface ValidateOptions { /** * Only validate the input, skipping type casting and transformation. Default - false @@ -58,10 +67,11 @@ export interface InternalOptions __validating?: boolean; originalValue?: any; index?: number; + key?: string; parent?: any; path?: string; sync?: boolean; - from?: { schema: ISchema; value: any }[]; + from?: Ancester[]; } export interface MessageParams { @@ -70,6 +80,7 @@ export interface MessageParams { originalValue: any; label: string; type: string; + spec: SchemaSpec & Record; } export type Message = any> = @@ -80,3 +91,12 @@ export type Message = any> = export type ExtraParams = Record; export type AnyMessageParams = MessageParams & ExtraParams; + +export interface NestedTestConfig { + options: InternalOptions; + parent: any; + originalParent: any; + parentPath: string | undefined; + key?: string; + index?: number; +} diff --git a/src/util/cloneDeep.ts b/src/util/cloneDeep.ts new file mode 100644 index 000000000..78a76b53c --- /dev/null +++ b/src/util/cloneDeep.ts @@ -0,0 +1,45 @@ +// tweaked from https://github.com/Kelin2025/nanoclone/blob/0abeb7635bda9b68ef2277093f76dbe3bf3948e1/src/index.js +// MIT licensed + +import isSchema from './isSchema'; + +function clone(src: unknown, seen: Map = new Map()) { + if (isSchema(src) || !src || typeof src !== 'object') return src; + if (seen.has(src)) return seen.get(src); + + let copy: any; + if (src instanceof Date) { + // Date + copy = new Date(src.getTime()); + seen.set(src, copy); + } else if (src instanceof RegExp) { + // RegExp + copy = new RegExp(src); + seen.set(src, copy); + } else if (Array.isArray(src)) { + // Array + copy = new Array(src.length); + seen.set(src, copy); + for (let i = 0; i < src.length; i++) copy[i] = clone(src[i], seen); + } else if (src instanceof Map) { + // Map + copy = new Map(); + seen.set(src, copy); + for (const [k, v] of src.entries()) copy.set(k, clone(v, seen)); + } else if (src instanceof Set) { + // Set + copy = new Set(); + seen.set(src, copy); + for (const v of src) copy.add(clone(v, seen)); + } else if (src instanceof Object) { + // Object + copy = {}; + seen.set(src, copy); + for (const [k, v] of Object.entries(src)) copy[k] = clone(v, seen); + } else { + throw Error(`Unable to clone ${src}`); + } + return copy; +} + +export default clone; diff --git a/src/util/collectErrors.ts b/src/util/collectErrors.ts new file mode 100644 index 000000000..11837d3dd --- /dev/null +++ b/src/util/collectErrors.ts @@ -0,0 +1,3 @@ +export default function toArray(value?: null | T | readonly T[]) { + return value == null ? [] : ([] as T[]).concat(value); +} diff --git a/src/util/createValidation.ts b/src/util/createValidation.ts index 07e5bd1f6..7ea771078 100644 --- a/src/util/createValidation.ts +++ b/src/util/createValidation.ts @@ -6,6 +6,7 @@ import { InternalOptions, ExtraParams, ISchema, + MessageParams, } from '../types'; import Reference from '../Reference'; import type { AnySchema } from '../schema'; @@ -29,7 +30,7 @@ export type TestContext = { originalValue: any; parent: any; from?: Array<{ schema: ISchema; value: any }>; - schema: any; // TODO: Schema; + schema: any; resolve: (value: T | Reference) => T; createError: (params?: CreateErrorOptions) => ValidationError; }; @@ -48,6 +49,7 @@ export type TestOptions = { originalValue: any; schema: TSchema; sync?: boolean; + spec: MessageParams['spec']; }; export type TestConfig = { @@ -79,6 +81,7 @@ export default function createValidation(config: { label, options, originalValue, + spec, sync, ...rest }: TestOptions, @@ -98,6 +101,7 @@ export default function createValidation(config: { originalValue, label, path: overrides.path || path, + spec, ...params, ...overrides.params, }; diff --git a/src/util/reach.ts b/src/util/reach.ts index b65c76ac1..8f3dc53b6 100644 --- a/src/util/reach.ts +++ b/src/util/reach.ts @@ -2,7 +2,12 @@ import { forEach } from 'property-expr'; let trim = (part: string) => part.substr(0, part.length - 1).substr(1); -export function getIn(schema: any, path: string, value?: any, context = value) { +export function getIn( + schema: any, + path: string, + value?: any, + context: C = value, +) { let parent: any, lastPart: string, lastPartDebug: string; // root path: '' @@ -13,9 +18,14 @@ export function getIn(schema: any, path: string, value?: any, context = value) { schema = schema.resolve({ context, parent, value }); - if (schema.innerType) { - let idx = isArray ? parseInt(part, 10) : 0; + let isTuple = schema.type === 'tuple'; + let idx = isArray ? parseInt(part, 10) : 0; + if (schema.innerType || isTuple) { + if (isTuple && !isArray) + throw new Error( + `Yup.reach cannot implicitly index into a tuple type. the path part "${lastPartDebug}" must contain an index to the tuple element, e.g. "${lastPartDebug}[0]"`, + ); if (value && idx >= value.length) { throw new Error( `Yup.reach cannot resolve an array item at index: ${_part}, in the path: ${path}. ` + @@ -24,7 +34,7 @@ export function getIn(schema: any, path: string, value?: any, context = value) { } parent = value; value = value && value[idx]; - schema = schema.innerType; + schema = isTuple ? schema.spec.types[idx] : schema.innerType!; } // sometimes the array index part of a path doesn't exist: "nested.arr.child" @@ -35,7 +45,7 @@ export function getIn(schema: any, path: string, value?: any, context = value) { if (!schema.fields || !schema.fields[part]) throw new Error( `The schema does not contain the path: ${path}. ` + - `(failed at: ${lastPartDebug} which is a type: "${schema._type}")`, + `(failed at: ${lastPartDebug} which is a type: "${schema.type}")`, ); parent = value; @@ -50,7 +60,7 @@ export function getIn(schema: any, path: string, value?: any, context = value) { return { schema, parent, parentPath: lastPart! }; } -const reach = (obj: {}, path: string, value?: any, context?: any) => +const reach = (obj: any, path: string, value?: any, context?: any) => getIn(obj, path, value, context).schema; export default reach; diff --git a/test/mixed.ts b/test/mixed.ts index 5ad5e5199..7bc0d2092 100644 --- a/test/mixed.ts +++ b/test/mixed.ts @@ -323,8 +323,8 @@ describe('Mixed Types ', () => { let inst = mixed().test('test', noop); expect(inst.tests).toHaveLength(1); - expect(inst.tests[0].OPTIONS.test).toBe(noop); - expect(inst.tests[0].OPTIONS.message).toBe('${path} is invalid'); + expect(inst.tests[0]!.OPTIONS!.test).toBe(noop); + expect(inst.tests[0]!.OPTIONS!.message).toBe('${path} is invalid'); }); it('should fallback to default message', async () => { @@ -338,7 +338,7 @@ describe('Mixed Types ', () => { let inst = mixed().test('test', message, () => false); expect(inst.tests).toHaveLength(1); - expect(inst.tests[0].OPTIONS.message).toBe(message); + expect(inst.tests[0]!.OPTIONS!.message).toBe(message); let err = await inst.validate('foo').catch((err) => err); expect(err.message).toEqual(message); @@ -348,7 +348,7 @@ describe('Mixed Types ', () => { let inst = mixed().test('test', ' ', noop).test('test', 'asdasd', noop); expect(inst.tests).toHaveLength(1); - expect(inst.tests[0].OPTIONS.message).toBe('asdasd'); + expect(inst.tests[0]!.OPTIONS!.message).toBe('asdasd'); }); it('should not dedupe tests with the same test function and different type', () => { @@ -685,7 +685,7 @@ describe('Mixed Types ', () => { let inst = mixed().default('hi'); expect(function () { - expect(inst.concat(string())._type).toBe('string'); + expect(inst.concat(string()).type).toBe('string'); }).not.toThrowError(TypeError); }); diff --git a/test/object.ts b/test/object.ts index ba48d54b6..c16726c45 100644 --- a/test/object.ts +++ b/test/object.ts @@ -717,7 +717,10 @@ describe('Object types', () => { it('should handle conditionals', () => { let inst = object().shape({ noteDate: number() - .when('stats.isBig', { is: true, then: (s) => s.min(5) }) + .when('stats.isBig', { + is: true, + then: (s) => s.min(5), + }) .when('other', ([v], schema) => (v === 4 ? schema.max(6) : schema)), stats: object({ isBig: bool() }), other: number() @@ -727,6 +730,7 @@ describe('Object types', () => { return Promise.all([ expect( + // other makes noteDate too large inst.isValid({ stats: { isBig: true }, rand: 5, diff --git a/test/tuple.ts b/test/tuple.ts new file mode 100644 index 000000000..0fe576a1f --- /dev/null +++ b/test/tuple.ts @@ -0,0 +1,112 @@ +import { string, number, object, tuple, mixed } from '../src'; + +describe('Array types', () => { + describe('casting', () => { + it('should failed casts return input', () => { + expect( + tuple([number(), number()]).cast('asfasf', { assert: false }), + ).toEqual('asfasf'); + }); + + it('should recursively cast fields', () => { + expect(tuple([number(), number()]).cast(['4', '5'])).toEqual([4, 5]); + + expect( + tuple([string(), string(), string()]).cast(['4', 5, false]), + ).toEqual(['4', '5', 'false']); + }); + }); + + it('should handle DEFAULT', () => { + expect(tuple([number(), number(), number()]).getDefault()).toBeUndefined(); + + expect( + tuple([number(), number(), number()]) + .default(() => [1, 2, 3]) + .getDefault(), + ).toEqual([1, 2, 3]); + }); + + it('should type check', () => { + let inst = tuple([number()]); + + expect(inst.isType([1])).toBe(true); + expect(inst.isType({})).toBe(false); + expect(inst.isType('true')).toBe(false); + expect(inst.isType(NaN)).toBe(false); + expect(inst.isType(34545)).toBe(false); + + expect(inst.isType(null)).toBe(false); + + expect(inst.nullable().isType(null)).toBe(true); + }); + + it('should pass options to children', () => { + expect( + tuple([object({ name: string() })]).cast([{ id: 1, name: 'john' }], { + stripUnknown: true, + }), + ).toEqual([{ name: 'john' }]); + }); + + describe('validation', () => { + test.each([ + ['required', undefined, tuple([mixed()]).required()], + ['required', null, tuple([mixed()]).required()], + ['null', null, tuple([mixed()])], + ])('Basic validations fail: %s %p', async (_, value, schema) => { + expect(await schema.isValid(value)).toBe(false); + }); + + test.each([ + ['required', ['any'], tuple([mixed()]).required()], + ['nullable', null, tuple([mixed()]).nullable()], + ])('Basic validations pass: %s %p', async (_, value, schema) => { + expect(await schema.isValid(value)).toBe(true); + }); + + it('should allow undefined', async () => { + await expect( + tuple([number().defined()]).isValid(undefined), + ).resolves.toBe(true); + }); + + it('should respect subtype validations', async () => { + let inst = tuple([number().max(5), string()]); + + await expect(inst.isValid(['gg', 'any'])).resolves.toBe(false); + await expect(inst.isValid([7, 3])).resolves.toBe(false); + + expect(await inst.validate(['4', 3])).toEqual([4, '3']); + }); + + it('should use labels', async () => { + let schema = tuple([ + string().label('name'), + number().positive().integer().label('age'), + ]); + + await expect(schema.validate(['James', -24.55])).rejects.toThrow( + 'age must be a positive number', + ); + }); + + it('should throw useful type error for lenght', async () => { + let schema = tuple([string().label('name'), number().label('age')]); + + // expect(() => schema.cast(['James'])).toThrowError( + // 'this tuple value has too few items, expected a length of 2 but got 1 for value', + // ); + await expect(schema.validate(['James'])).rejects.toThrowError( + 'this tuple value has too few items, expected a length of 2 but got 1 for value', + ); + + await expect(schema.validate(['James', 2, 4])).rejects.toThrowError( + 'this tuple value has too many items, expected a length of 2 but got 3 for value', + ); + // expect(() => schema.validate(['James', 2, 4])).rejects.toThrowError( + // 'this tuple value has too many items, expected a length of 2 but got 3 for value', + // ); + }); + }); +}); diff --git a/test/types/types.ts b/test/types/types.ts index 2079a3f52..683d8d0e1 100644 --- a/test/types/types.ts +++ b/test/types/types.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-expressions */ /* eslint-disable no-unused-labels */ import { array, number, string, date, ref, mixed, bool } from '../../src'; +import { create as tuple } from '../../src/tuple'; import { create as lazy } from '../../src/Lazy'; import ObjectSchema, { create as object } from '../../src/object'; @@ -353,7 +354,7 @@ date: { date().strip().strip(false).cast(undefined); } -date: { +bool: { const blRequired = bool().required(); // $ExpectType boolean @@ -556,6 +557,91 @@ Array: { } } +Tuple: { + // $ExpectType [number, string | undefined, { age: number; }] | undefined + tuple([ + number().defined(), + string(), + object({ age: number().required() }), + ]).cast([3, 4]); + + const tplRequired = tuple([ + string().required(), + string().required(), + ]).required(); + + // $ExpectType [string, string] + tplRequired.cast(undefined); + + // $ExpectType [string, string] | null + tplRequired.nullable().cast(undefined); + + // $ExpectType [string, string] + tplRequired.nullable().nonNullable().cast(undefined); + + // + const tplOptional = tuple([ + string().required(), + string().required(), + ]).optional(); + + // $ExpectType [string, string] | undefined + tplOptional.cast(undefined); + + // $ExpectType [string, string] + tplOptional.defined().cast(undefined); + + // + const tplNullableOptional = tuple([string().required(), string().required()]) + .nullable() + .optional(); + + // $ExpectType [string, string] | null | undefined + tplNullableOptional.cast(''); + + // $ExpectType [string, string] + tplNullableOptional.required().validateSync(''); + + // + const tplNullable = tuple([ + string().required(), + string().required(), + ]).nullable(); + + // $ExpectType [string, string] | null | undefined + tplNullable.validateSync(''); + + const tplDefined = tuple([string().required(), string().required()]).default( + () => ['', ''], + ); + + // $ExpectType [string, string] + tplDefined.getDefault(); + + const tplDefault = tuple([string().required(), string().required()]) + .nullable() + .default(['', '']) + .nullable(); + + // $ExpectType [string, string] | null + tplDefault.cast(''); + + // $ExpectType [string, string] | null + tplDefault.validateSync(''); + + // $ExpectType TupleSchema<[string, string], AnyObject, [string, string], "d"> + const tplDefaultRequired = tuple([string().required(), string().required()]) + .nullable() + .required() + .default(() => ['', '']); + + // $ExpectType [string, string] + tplDefaultRequired.cast(''); + + // $ExpectType [string, string] + tplDefaultRequired.validateSync(null); +} + Object: { const objRequired = object().required(); diff --git a/test/yup.js b/test/yup.js index 483dbd3b8..13931376a 100644 --- a/test/yup.js +++ b/test/yup.js @@ -18,6 +18,7 @@ import { DateSchema, mixed, MixedSchema, + tuple, } from '../src'; describe('Yup', function () { @@ -96,22 +97,27 @@ describe('Yup', function () { let inst = object({ num: number().max(4), - nested: object({ - arr: array().of(shape), - }), + nested: tuple([ + string(), + object({ + arr: array().of(shape), + }), + ]), }); expect(reach(inst, '')).toBe(inst); - expect(reach(inst, 'nested.arr[0].num')).toBe(num); - expect(reach(inst, 'nested.arr[].num')).toBe(num); - expect(reach(inst, 'nested.arr[1].num')).toBe(num); - expect(reach(inst, 'nested.arr[1]')).toBe(shape); + expect(reach(inst, 'nested[1].arr[0].num')).toBe(num); + expect(reach(inst, 'nested[1].arr[].num')).toBe(num); + expect(reach(inst, 'nested[1].arr.num')).toBe(num); + expect(reach(inst, 'nested[1].arr[1].num')).toBe(num); + expect(reach(inst, 'nested[1].arr[1]')).toBe(shape); - expect(reach(inst, 'nested["arr"][1].num')).not.toBe(number()); + expect(() => reach(inst, 'nested.arr[1].num')).toThrowError( + 'Yup.reach cannot implicitly index into a tuple type. the path part ".nested" must contain an index to the tuple element, e.g. ".nested[0]"', + ); - let valid = await reach(inst, 'nested.arr[0].num').isValid(5); - expect(valid).toBe(true); + expect(reach(inst, 'nested[1].arr[0].num').isValid(5)).resolves.toBe(true); }); it('should REACH conditionally correctly', async function () {