Skip to content

Commit

Permalink
feat: add Tuple type (#1546)
Browse files Browse the repository at this point in the history
* WIP

* feat: add tuple type

* docs, errors

* support reach
  • Loading branch information
jquense authored Jan 7, 2022
1 parent 635a204 commit a8febdd
Show file tree
Hide file tree
Showing 19 changed files with 595 additions and 75 deletions.
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<typeof schema> // [string, number] | undefined
```

tuples have no default casting behavior.

### object

Define an object schema. Options passed into `isValid` are also passed to child schemas.
Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 9 additions & 3 deletions src/Lazy.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -82,8 +87,9 @@ class Lazy<T, TContext = AnyObject, TDefault = any, TFlags extends Flags = any>
return this._resolve(value, options).cast(value, options);
}

asTest(value: any, options?: ValidateOptions<TContext>) {
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<TContext>): Promise<T> {
Expand Down
23 changes: 9 additions & 14 deletions src/array.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export default class ArraySchema<
options: InternalOptions<TContext> = {},

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;
Expand All @@ -99,25 +99,21 @@ export default class ArraySchema<

super._validate(_value, options, panic, (arrayErrors, value) => {
if (!recursive || !innerType || !this._typeCheck(value)) {
callback(arrayErrors, value);
next(arrayErrors, value);
return;
}

originalValue = originalValue || value;

// #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,
});
}

Expand All @@ -127,8 +123,7 @@ export default class ArraySchema<
tests,
},
panic,
(innerTypeErrors) =>
callback(innerTypeErrors.concat(arrayErrors), value),
(innerTypeErrors) => next(innerTypeErrors.concat(arrayErrors), value),
);
});
}
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -62,6 +63,7 @@ export {
arrayCreate as array,
refCreate as ref,
lazyCreate as lazy,
tupleCreate as tuple,
reach,
getIn,
isSchema,
Expand All @@ -79,6 +81,7 @@ export {
DateSchema,
ObjectSchema,
ArraySchema,
TupleSchema,
};

export type {
Expand Down
24 changes: 24 additions & 0 deletions src/locale.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import printValue from './util/printValue';
import { Message } from './types';
import ValidationError from './ValidationError';

export interface MixedLocale {
default?: Message;
Expand Down Expand Up @@ -49,6 +50,10 @@ export interface ArrayLocale {
max?: Message<{ max: number }>;
}

export interface TupleLocale {
notType?: Message;
}

export interface BooleanLocale {
isValue?: Message;
}
Expand Down Expand Up @@ -128,6 +133,25 @@ export let array: Required<ArrayLocale> = {
length: '${path} must have ${length} items',
};

export let tuple: Required<TupleLocale> = {
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,
Expand Down
29 changes: 11 additions & 18 deletions src/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,25 +198,23 @@ export default class ObjectSchema<

protected _validate(
_value: any,
opts: InternalOptions<TContext> = {},
options: InternalOptions<TContext> = {},
panic: (err: Error, value: unknown) => void,
next: (err: ValidationError[], value: unknown) => void,
) {
let {
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;
Expand All @@ -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,
}),
);
}
Expand Down
64 changes: 52 additions & 12 deletions src/schema.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
// @ts-ignore
import cloneDeep from 'nanoclone';

import { mixed as locale } from './locale';
import Condition, {
ConditionBuilder,
Expand All @@ -26,6 +23,7 @@ import {
ExtraParams,
AnyObject,
ISchema,
NestedTestConfig,
} from './types';

import ValidationError from './ValidationError';
Expand All @@ -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<TDefault> = {
coarce: boolean;
Expand All @@ -50,7 +49,7 @@ export type SchemaSpec<TDefault> = {

export type SchemaOptions<TType, TDefault> = {
type: string;
spec?: SchemaSpec<TDefault>;
spec?: Partial<SchemaSpec<TDefault>>;
check: (value: any) => value is NonNullable<TType>;
};

Expand Down Expand Up @@ -316,6 +315,16 @@ export default abstract class Schema<
return schema;
}

protected resolveOptions<T extends InternalOptions<any>>(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.
*/
Expand All @@ -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}`
Expand Down Expand Up @@ -390,6 +399,7 @@ export default abstract class Schema<
originalValue,
schema: this,
label: this.spec.label,
spec: this.spec,
sync,
from,
};
Expand Down Expand Up @@ -470,11 +480,41 @@ export default abstract class Schema<
}
}

asTest(value: any, options?: ValidateOptions<TContext>): 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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Loading

0 comments on commit a8febdd

Please sign in to comment.