Skip to content

Commit a8febdd

Browse files
authored
feat: add Tuple type (#1546)
* WIP * feat: add tuple type * docs, errors * support reach
1 parent 635a204 commit a8febdd

19 files changed

+595
-75
lines changed

README.md

+24
Original file line numberDiff line numberDiff line change
@@ -1231,6 +1231,7 @@ await schema.isValid('hello'); // => true
12311231
```
12321232

12331233
By default, the `cast` logic of `string` is to call `toString` on the value if it exists.
1234+
12341235
empty values are not coerced (use `ensure()` to coerce empty values to empty strings).
12351236

12361237
Failed casts return the input value.
@@ -1469,6 +1470,29 @@ array()
14691470
.cast(['', 1, 0, 4, false, null]); // => ['', 1, 0, 4, false]
14701471
```
14711472

1473+
### tuple
1474+
1475+
Tuples, are fixed length arrays where each item has a distinct type.
1476+
1477+
Inherits from [`Schema`](#Schema).
1478+
1479+
```js
1480+
import { tuple, string, number, InferType } from 'yup';
1481+
1482+
let schema = tuple([
1483+
string().label('name'),
1484+
number().label('age').positive().integer(),
1485+
]);
1486+
1487+
await schema.validate(['James', 3]); // ['James', 3]
1488+
1489+
await schema.validate(['James', -24]); // => ValidationError: age must be a positive number
1490+
1491+
InferType<typeof schema> // [string, number] | undefined
1492+
```
1493+
1494+
tuples have no default casting behavior.
1495+
14721496
### object
14731497

14741498
Define an object schema. Options passed into `isValid` are also passed to child schemas.

package.json

-1
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,6 @@
101101
"typescript": "^4.5.4"
102102
},
103103
"dependencies": {
104-
"nanoclone": "^1.0.0",
105104
"property-expr": "^2.0.4",
106105
"tiny-case": "^1.0.2",
107106
"toposort": "^2.0.2"

src/Lazy.ts

+9-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import isSchema from './util/isSchema';
2-
import type { AnyObject, ISchema, ValidateOptions } from './types';
2+
import type {
3+
AnyObject,
4+
ISchema,
5+
ValidateOptions,
6+
NestedTestConfig,
7+
} from './types';
38
import type { ResolveOptions } from './Condition';
49

510
import type {
@@ -82,8 +87,9 @@ class Lazy<T, TContext = AnyObject, TDefault = any, TFlags extends Flags = any>
8287
return this._resolve(value, options).cast(value, options);
8388
}
8489

85-
asTest(value: any, options?: ValidateOptions<TContext>) {
86-
return this._resolve(value, options).asTest(value, options);
90+
asNestedTest(options: NestedTestConfig) {
91+
let value = options.parent[options.index ?? options.key!];
92+
return this._resolve(value, options).asNestedTest(options);
8793
}
8894

8995
validate(value: any, options?: ValidateOptions<TContext>): Promise<T> {

src/array.ts

+9-14
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export default class ArraySchema<
8686
options: InternalOptions<TContext> = {},
8787

8888
panic: (err: Error, value: unknown) => void,
89-
callback: (err: ValidationError[], value: unknown) => void,
89+
next: (err: ValidationError[], value: unknown) => void,
9090
) {
9191
// let sync = options.sync;
9292
// let path = options.path;
@@ -99,25 +99,21 @@ export default class ArraySchema<
9999

100100
super._validate(_value, options, panic, (arrayErrors, value) => {
101101
if (!recursive || !innerType || !this._typeCheck(value)) {
102-
callback(arrayErrors, value);
102+
next(arrayErrors, value);
103103
return;
104104
}
105105

106106
originalValue = originalValue || value;
107107

108108
// #950 Ensure that sparse array empty slots are validated
109109
let tests: RunTest[] = new Array(value.length);
110-
for (let idx = 0; idx < value.length; idx++) {
111-
let item = value[idx];
112-
let path = `${options.path || ''}[${idx}]`;
113-
114-
tests[idx] = innerType!.asTest(item, {
115-
...options,
116-
path,
110+
for (let index = 0; index < value.length; index++) {
111+
tests[index] = innerType!.asNestedTest({
112+
options,
113+
index,
117114
parent: value,
118-
// FIXME
119-
index: idx,
120-
originalValue: originalValue[idx],
115+
parentPath: options.path,
116+
originalParent: options.originalValue ?? _value,
121117
});
122118
}
123119

@@ -127,8 +123,7 @@ export default class ArraySchema<
127123
tests,
128124
},
129125
panic,
130-
(innerTypeErrors) =>
131-
callback(innerTypeErrors.concat(arrayErrors), value),
126+
(innerTypeErrors) => next(innerTypeErrors.concat(arrayErrors), value),
132127
);
133128
});
134129
}

src/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import NumberSchema, { create as numberCreate } from './number';
99
import DateSchema, { create as dateCreate } from './date';
1010
import ObjectSchema, { AnyObject, create as objectCreate } from './object';
1111
import ArraySchema, { create as arrayCreate } from './array';
12+
import TupleSchema, { create as tupleCreate } from './tuple';
1213
import { create as refCreate } from './Reference';
1314
import { create as lazyCreate } from './Lazy';
1415
import ValidationError from './ValidationError';
@@ -62,6 +63,7 @@ export {
6263
arrayCreate as array,
6364
refCreate as ref,
6465
lazyCreate as lazy,
66+
tupleCreate as tuple,
6567
reach,
6668
getIn,
6769
isSchema,
@@ -79,6 +81,7 @@ export {
7981
DateSchema,
8082
ObjectSchema,
8183
ArraySchema,
84+
TupleSchema,
8285
};
8386

8487
export type {

src/locale.ts

+24
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import printValue from './util/printValue';
22
import { Message } from './types';
3+
import ValidationError from './ValidationError';
34

45
export interface MixedLocale {
56
default?: Message;
@@ -49,6 +50,10 @@ export interface ArrayLocale {
4950
max?: Message<{ max: number }>;
5051
}
5152

53+
export interface TupleLocale {
54+
notType?: Message;
55+
}
56+
5257
export interface BooleanLocale {
5358
isValue?: Message;
5459
}
@@ -128,6 +133,25 @@ export let array: Required<ArrayLocale> = {
128133
length: '${path} must have ${length} items',
129134
};
130135

136+
export let tuple: Required<TupleLocale> = {
137+
notType: (params) => {
138+
const { path, value, spec } = params;
139+
const typeLen = spec.types.length;
140+
if (Array.isArray(value)) {
141+
if (value.length < typeLen)
142+
return `${path} tuple value has too few items, expected a length of ${typeLen} but got ${
143+
value.length
144+
} for value: \`${printValue(value, true)}\``;
145+
if (value.length > typeLen)
146+
return `${path} tuple value has too many items, expected a length of ${typeLen} but got ${
147+
value.length
148+
} for value: \`${printValue(value, true)}\``;
149+
}
150+
151+
return ValidationError.formatError(mixed.notType, params);
152+
},
153+
};
154+
131155
export default Object.assign(Object.create(null), {
132156
mixed,
133157
string,

src/object.ts

+11-18
Original file line numberDiff line numberDiff line change
@@ -198,25 +198,23 @@ export default class ObjectSchema<
198198

199199
protected _validate(
200200
_value: any,
201-
opts: InternalOptions<TContext> = {},
201+
options: InternalOptions<TContext> = {},
202202
panic: (err: Error, value: unknown) => void,
203203
next: (err: ValidationError[], value: unknown) => void,
204204
) {
205205
let {
206206
from = [],
207207
originalValue = _value,
208208
recursive = this.spec.recursive,
209-
} = opts;
210-
211-
from = [{ schema: this, value: originalValue }, ...from];
209+
} = options;
212210

211+
options.from = [{ schema: this, value: originalValue }, ...from];
213212
// this flag is needed for handling `strict` correctly in the context of
214213
// validation vs just casting. e.g strict() on a field is only used when validating
215-
opts.__validating = true;
216-
opts.originalValue = originalValue;
217-
opts.from = from;
214+
options.__validating = true;
215+
options.originalValue = originalValue;
218216

219-
super._validate(_value, opts, panic, (objectErrors, value) => {
217+
super._validate(_value, options, panic, (objectErrors, value) => {
220218
if (!recursive || !isObject(value)) {
221219
next(objectErrors, value);
222220
return;
@@ -232,18 +230,13 @@ export default class ObjectSchema<
232230
continue;
233231
}
234232

235-
let path =
236-
key.indexOf('.') === -1
237-
? (opts.path ? `${opts.path}.` : '') + key
238-
: `${opts.path || ''}["${key}"]`;
239-
240233
tests.push(
241-
field.asTest(value[key], {
242-
...opts,
243-
path,
244-
from,
234+
field.asNestedTest({
235+
options,
236+
key,
245237
parent: value,
246-
originalValue: originalValue[key],
238+
parentPath: options.path,
239+
originalParent: originalValue,
247240
}),
248241
);
249242
}

src/schema.ts

+52-12
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
// @ts-ignore
2-
import cloneDeep from 'nanoclone';
3-
41
import { mixed as locale } from './locale';
52
import Condition, {
63
ConditionBuilder,
@@ -26,6 +23,7 @@ import {
2623
ExtraParams,
2724
AnyObject,
2825
ISchema,
26+
NestedTestConfig,
2927
} from './types';
3028

3129
import ValidationError from './ValidationError';
@@ -34,6 +32,7 @@ import Reference from './Reference';
3432
import isAbsent from './util/isAbsent';
3533
import type { Flags, Maybe, ResolveFlags, Thunk, _ } from './util/types';
3634
import toArray from './util/toArray';
35+
import cloneDeep from './util/cloneDeep';
3736

3837
export type SchemaSpec<TDefault> = {
3938
coarce: boolean;
@@ -50,7 +49,7 @@ export type SchemaSpec<TDefault> = {
5049

5150
export type SchemaOptions<TType, TDefault> = {
5251
type: string;
53-
spec?: SchemaSpec<TDefault>;
52+
spec?: Partial<SchemaSpec<TDefault>>;
5453
check: (value: any) => value is NonNullable<TType>;
5554
};
5655

@@ -316,6 +315,16 @@ export default abstract class Schema<
316315
return schema;
317316
}
318317

318+
protected resolveOptions<T extends InternalOptions<any>>(options: T): T {
319+
return {
320+
...options,
321+
from: options.from || [],
322+
strict: options.strict ?? this.spec.strict,
323+
abortEarly: options.abortEarly ?? this.spec.abortEarly,
324+
recursive: options.recursive ?? this.spec.recursive,
325+
};
326+
}
327+
319328
/**
320329
* Run the configured transform pipeline over an input value.
321330
*/
@@ -336,7 +345,7 @@ export default abstract class Schema<
336345
`The value of ${
337346
options.path || 'field'
338347
} could not be cast to a value ` +
339-
`that satisfies the schema type: "${resolvedSchema._type}". \n\n` +
348+
`that satisfies the schema type: "${resolvedSchema.type}". \n\n` +
340349
`attempted value: ${formattedValue} \n` +
341350
(formattedResult !== formattedValue
342351
? `result of cast: ${formattedResult}`
@@ -390,6 +399,7 @@ export default abstract class Schema<
390399
originalValue,
391400
schema: this,
392401
label: this.spec.label,
402+
spec: this.spec,
393403
sync,
394404
from,
395405
};
@@ -470,11 +480,41 @@ export default abstract class Schema<
470480
}
471481
}
472482

473-
asTest(value: any, options?: ValidateOptions<TContext>): RunTest {
474-
// Nested validations fields are always strict:
475-
// 1. parent isn't strict so the casting will also have cast inner values
476-
// 2. parent is strict in which case the nested values weren't cast either
477-
const testOptions = { ...options, strict: true, value };
483+
asNestedTest({
484+
key,
485+
index,
486+
parent,
487+
parentPath,
488+
originalParent,
489+
options,
490+
}: NestedTestConfig): RunTest {
491+
const k = key ?? index;
492+
if (k == null) {
493+
throw TypeError('Must include `key` or `index` for nested validations');
494+
}
495+
496+
const isIndex = typeof k === 'number';
497+
let value = parent[k];
498+
499+
const testOptions = {
500+
...options,
501+
// Nested validations fields are always strict:
502+
// 1. parent isn't strict so the casting will also have cast inner values
503+
// 2. parent is strict in which case the nested values weren't cast either
504+
strict: true,
505+
parent,
506+
value,
507+
originalValue: originalParent[k],
508+
// FIXME: tests depend on `index` being passed around deeply,
509+
// we should not let the options.key/index bleed through
510+
key: undefined,
511+
// index: undefined,
512+
[isIndex ? 'index' : 'key']: k,
513+
path:
514+
isIndex || k.includes('.')
515+
? `${parentPath || ''}[${value ? k : `"${k}"`}]`
516+
: (parentPath ? `${parentPath}.` : '') + key,
517+
};
478518

479519
return (_: any, panic, next) =>
480520
this.resolve(testOptions)._validate(value, testOptions, panic, next);
@@ -785,7 +825,7 @@ export default abstract class Schema<
785825
if (!isAbsent(value) && !this.schema._typeCheck(value))
786826
return this.createError({
787827
params: {
788-
type: this.schema._type,
828+
type: this.schema.type,
789829
},
790830
});
791831
return true;
@@ -926,7 +966,7 @@ for (const method of ['validate', 'validateSync'])
926966
value,
927967
options.context,
928968
);
929-
return schema[method](parent && parent[parentPath], {
969+
return (schema as any)[method](parent && parent[parentPath], {
930970
...options,
931971
parent,
932972
path,

0 commit comments

Comments
 (0)