Repository: jquense/yup
Pull request #1546 (2022-01-03 15:27:53)

WIP:  add Tuple


Patch:
diff --git a/README.md b/README.md
index 7d06e04..a3741f3 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@ Schema are comprised of parsing actions (transforms) as well as assertions (test
 Validate an input value to parse it and run the configured set of assertions. Chain together methods to build a schema.
 
 ```ts
-import { object, string, string, date, InferType } from 'yup';
+import { object, string, number, date, InferType } from 'yup';
 
 let userSchema = object({
   name: string().required(),
@@ -294,7 +294,7 @@ interface Person extends yup.InferType<typeof personSchema> {}
 
 ### Schema defaults
 
-a schema's default is used when casting produces an `undefined` putput value. Because of this,
+a schema's default is used when casting produces an `undefined` output value. Because of this,
 setting a default affects the output type of the schema, effectively marking it as "defined()".
 
 ```ts
@@ -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<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.
diff --git a/package.json b/package.json
index beb5c1c..448ef32 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 74aa2fa..1946966 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<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> {
diff --git a/src/array.ts b/src/array.ts
index 675b737..dd9a086 100644
--- a/src/array.ts
+++ b/src/array.ts
@@ -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;
@@ -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 50d673f..18e5c3b 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 791912d..6f8a20d 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<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,
diff --git a/src/object.ts b/src/object.ts
index c90b98b..542119f 100644
--- a/src/object.ts
+++ b/src/object.ts
@@ -198,7 +198,7 @@ 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,
   ) {
@@ -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 607b69c..b4ccef7 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<TDefault> = {
   coarce: boolean;
@@ -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>;
 };
 
@@ -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.
    */
@@ -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<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);
@@ -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 0000000..f3a4d69
--- /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<T extends AnyTuple>(schemas: {
+  [K in keyof T]: ISchema<T[K]>;
+}) {
+  return new TupleSchema<T | undefined>(schemas);
+}
+
+export default interface TupleSchema<
+  TType extends Maybe<AnyTuple> = AnyTuple | undefined,
+  TContext = AnyObject,
+  TDefault = undefined,
+  TFlags extends Flags = '',
+> extends Schema<TType, TContext, TDefault, TFlags> {
+  default<D extends Maybe<TType>>(
+    def: Thunk<D>,
+  ): TupleSchema<TType, TContext, D, ToggleDefault<TFlags, D>>;
+
+  concat<TOther extends TupleSchema<any, any>>(schema: TOther): TOther;
+
+  defined(
+    msg?: Message,
+  ): TupleSchema<Defined<TType>, TContext, TDefault, TFlags>;
+  optional(): TupleSchema<TType | undefined, TContext, TDefault, TFlags>;
+
+  required(
+    msg?: Message,
+  ): TupleSchema<NonNullable<TType>, TContext, TDefault, TFlags>;
+  notRequired(): TupleSchema<Maybe<TType>, TContext, TDefault, TFlags>;
+
+  nullable(
+    msg?: Message,
+  ): TupleSchema<TType | null, TContext, TDefault, TFlags>;
+  nonNullable(): TupleSchema<NotNull<TType>, TContext, TDefault, TFlags>;
+
+  strip(
+    enabled: false,
+  ): TupleSchema<TType, TContext, TDefault, UnsetFlag<TFlags, 's'>>;
+  strip(
+    enabled?: true,
+  ): TupleSchema<TType, TContext, TDefault, SetFlag<TFlags, 's'>>;
+}
+
+interface TupleSchemaSpec<T> extends SchemaSpec<any> {
+  types: T extends any[]
+    ? {
+        [K in keyof T]: ISchema<T[K]>;
+      }
+    : never;
+}
+
+export default class TupleSchema<
+  TType extends Maybe<AnyTuple> = AnyTuple | undefined,
+  TContext = AnyObject,
+  TDefault = undefined,
+  TFlags extends Flags = '',
+> extends Schema<TType, TContext, TDefault, TFlags> {
+  declare spec: TupleSchemaSpec<TType>;
+
+  constructor(schemas: [ISchema<any>, ...ISchema<any>[]]) {
+    super({
+      type: 'tuple',
+      spec: { types: schemas } as any,
+      check(v: any): v is NonNullable<TType> {
+        const types = (this.spec as TupleSchemaSpec<TType>).types;
+        return Array.isArray(v) && v.length === types.length;
+      },
+    });
+
+    this.withMutation(() => {
+      this.typeError(tupleLocale.notType);
+    });
+  }
+
+  protected _cast(inputValue: any, options: InternalOptions<TContext>) {
+    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<TContext> = {},
+    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 8c6abf2..592c93a 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<T, C = AnyObject, F extends Flags = any, D = any> {
   cast(value: any, options?: CastOptions<C>): T;
   validate(value: any, options?: ValidateOptions<C>): Promise<T>;
 
-  asTest(value: any, options?: InternalOptions<C>): Test;
+  asNestedTest(config: NestedTestConfig): Test;
 
   describe(options?: ResolveOptions<C>): SchemaFieldDescription;
   resolve(options: ResolveOptions<C>): ISchema<T, C, F>;
@@ -30,6 +35,10 @@ export type TransformFunction<T extends AnySchema> = (
   schema: T,
 ) => any;
 
+export interface Ancester<TContext> {
+  schema: ISchema<any, TContext>;
+  value: any;
+}
 export interface ValidateOptions<TContext = {}> {
   /**
    * Only validate the input, skipping type casting and transformation. Default - false
@@ -58,10 +67,11 @@ export interface InternalOptions<TContext = {}>
   __validating?: boolean;
   originalValue?: any;
   index?: number;
+  key?: string;
   parent?: any;
   path?: string;
   sync?: boolean;
-  from?: { schema: ISchema<any, TContext>; value: any }[];
+  from?: Ancester<TContext>[];
 }
 
 export interface MessageParams {
@@ -70,6 +80,7 @@ export interface MessageParams {
   originalValue: any;
   label: string;
   type: string;
+  spec: SchemaSpec<any> & Record<string, unknown>;
 }
 
 export type Message<Extra extends Record<string, unknown> = any> =
@@ -80,3 +91,12 @@ export type Message<Extra extends Record<string, unknown> = any> =
 export type ExtraParams = Record<string, unknown>;
 
 export type AnyMessageParams = MessageParams & ExtraParams;
+
+export interface NestedTestConfig {
+  options: InternalOptions<any>;
+  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 0000000..78a76b5
--- /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<any, any> = 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 0000000..11837d3
--- /dev/null
+++ b/src/util/collectErrors.ts
@@ -0,0 +1,3 @@
+export default function toArray<T>(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 07e5bd1..7ea7710 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<TContext = {}> = {
   originalValue: any;
   parent: any;
   from?: Array<{ schema: ISchema<any, TContext>; value: any }>;
-  schema: any; // TODO: Schema<any>;
+  schema: any;
   resolve: <T>(value: T | Reference<T>) => T;
   createError: (params?: CreateErrorOptions) => ValidationError;
 };
@@ -48,6 +49,7 @@ export type TestOptions<TSchema extends AnySchema = AnySchema> = {
   originalValue: any;
   schema: TSchema;
   sync?: boolean;
+  spec: MessageParams['spec'];
 };
 
 export type TestConfig<TValue = unknown, TContext = {}> = {
@@ -79,6 +81,7 @@ export default function createValidation(config: {
       label,
       options,
       originalValue,
+      spec,
       sync,
       ...rest
     }: TestOptions<TSchema>,
@@ -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 b65c76a..8f3dc53 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<C = any>(
+  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;


Test patch:
diff --git a/test/mixed.ts b/test/mixed.ts
index 5ad5e51..7bc0d20 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 ba48d54..c16726c 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 0000000..0fe576a
--- /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 2079a3f..683d8d0 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 483dbd3..1393137 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 () {