Repository: jquense/yup
Pull request #1758 (2022-08-22 20:15:46)

Better mixed generic and better `reach` typing


Patch:
diff --git a/README.md b/README.md
index 72c21dc..44545f2 100644
--- a/README.md
+++ b/README.md
@@ -1188,11 +1188,13 @@ module.exports = function (formats = 'MMM dd, yyyy') {
 ### mixed
 
 Creates a schema that matches all types, or just the ones you configure. Inherits from [`Schema`](#Schema).
+Mixed types extends `{}` by default instead of `any` or `unknown`. This is because in TypeScript `{}` means
+anything that isn't `null` or `undefined` which yup treats distinctly.
 
 ```ts
 import { mixed, InferType } from 'yup';
 
-let schema = mixed();
+let schema = mixed().null;
 
 schema.validateSync('string'); // 'string';
 
@@ -1200,10 +1202,13 @@ schema.validateSync(1); // 1;
 
 schema.validateSync(new Date()); // Date;
 
-InferType<typeof schema>; // any
+InferType<typeof schema>; // {} | undefined
+
+InferType<typeof schema.nullable().defined()>; // {} | null
 ```
 
-Custom types can be implemented by passing a type check function:
+Custom types can be implemented by passing a type `check` function. This will also
+narrow the TypeScript type for the schema.
 
 ```ts
 import { mixed, InferType } from 'yup';
diff --git a/package.json b/package.json
index 3feee2b..d75951b 100644
--- a/package.json
+++ b/package.json
@@ -103,6 +103,7 @@
   "dependencies": {
     "property-expr": "^2.0.5",
     "tiny-case": "^1.0.3",
-    "toposort": "^2.0.2"
+    "toposort": "^2.0.2",
+    "type-fest": "^2.19.0"
   }
 }
diff --git a/src/mixed.ts b/src/mixed.ts
index f5d82a4..73fd870 100644
--- a/src/mixed.ts
+++ b/src/mixed.ts
@@ -13,19 +13,22 @@ import Schema from './schema';
 
 const returnsTrue: any = () => true;
 
+type AnyPresentValue = {};
+
 export type TypeGuard<TType> = (value: any) => value is NonNullable<TType>;
 export interface MixedOptions<TType> {
   type?: string;
   check?: TypeGuard<TType>;
 }
-export function create<TType = unknown>(
+
+export function create<TType extends AnyPresentValue>(
   spec?: MixedOptions<TType> | TypeGuard<TType>,
 ) {
   return new MixedSchema<TType | undefined>(spec);
 }
 
 export default class MixedSchema<
-  TType = unknown,
+  TType extends Maybe<AnyPresentValue> = AnyPresentValue | undefined,
   TContext = AnyObject,
   TDefault = undefined,
   TFlags extends Flags = '',
@@ -40,7 +43,7 @@ export default class MixedSchema<
 }
 
 export default interface MixedSchema<
-  TType = unknown,
+  TType extends Maybe<AnyPresentValue> = AnyPresentValue | undefined,
   TContext = AnyObject,
   TDefault = undefined,
   TFlags extends Flags = '',
diff --git a/src/util/objectTypes.ts b/src/util/objectTypes.ts
index b1b9c53..b376487 100644
--- a/src/util/objectTypes.ts
+++ b/src/util/objectTypes.ts
@@ -9,6 +9,8 @@ export type AnyObject = { [k: string]: any };
 export type TypeFromShape<S extends ObjectShape, _C> = {
   [K in keyof S]: S[K] extends ISchema<any, any>
     ? S[K]['__outputType']
+    : S[K] extends Reference<infer T>
+    ? T
     : unknown;
 };
 
diff --git a/src/util/reach.ts b/src/util/reach.ts
index 18c0609..c182d84 100644
--- a/src/util/reach.ts
+++ b/src/util/reach.ts
@@ -1,8 +1,7 @@
 import { forEach } from 'property-expr';
 import type Reference from '../Reference';
-import type { ISchema } from '../types';
-
-let trim = (part: string) => part.substr(0, part.length - 1).substr(1);
+import type { InferType, ISchema } from '../types';
+import type { Get } from 'type-fest';
 
 export function getIn<C = any>(
   schema: any,
@@ -10,7 +9,7 @@ export function getIn<C = any>(
   value?: any,
   context: C = value,
 ): {
-  schema: ISchema<any> | Reference;
+  schema: ISchema<any> | Reference<any>;
   parent: any;
   parentPath: string;
 } {
@@ -20,7 +19,7 @@ export function getIn<C = any>(
   if (!path) return { parent, parentPath: path, schema };
 
   forEach(path, (_part, isBracket, isArray) => {
-    let part = isBracket ? trim(_part) : _part;
+    let part = isBracket ? _part.slice(1, _part.length - 1) : _part;
 
     schema = schema.resolve({ context, parent, value });
 
@@ -66,7 +65,15 @@ export function getIn<C = any>(
   return { schema, parent, parentPath: lastPart! };
 }
 
-const reach = (obj: any, path: string, value?: any, context?: any) =>
-  getIn(obj, path, value, context).schema;
+function reach<P extends string, S extends ISchema<any>>(
+  obj: S,
+  path: P,
+  value?: any,
+  context?: any,
+):
+  | Reference<Get<InferType<S>, P>>
+  | ISchema<Get<InferType<S>, P>, S['__context']> {
+  return getIn(obj, path, value, context).schema as any;
+}
 
 export default reach;
diff --git a/yarn.lock b/yarn.lock
index 60d6009..044d702 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -9901,6 +9901,11 @@ type-fest@^2.0.0, type-fest@^2.5.0:
   resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.18.1.tgz#a94f068c60b5a2d6beccccffa711210d7dd99b38"
   integrity sha512-UKCINsd4qiATXD6OIlnQw9t1ux/n2ld+Nl0kzPbCONhCaUIS/BhJbNw14w6584HCQWf3frBK8vmWnGZq/sbPHQ==
 
+type-fest@^2.19.0:
+  version "2.19.0"
+  resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b"
+  integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==
+
 type-is@~1.6.17, type-is@~1.6.18:
   version "1.6.18"
   resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"


Test patch:
diff --git a/test/lazy.ts b/test/lazy.ts
index b98bb53..42973e2 100644
--- a/test/lazy.ts
+++ b/test/lazy.ts
@@ -1,4 +1,4 @@
-import { lazy, mixed, AnyObject, MixedSchemaClass } from '../src';
+import { lazy, mixed, AnyObject, MixedSchema } from '../src';
 
 describe('lazy', function () {
   it('should throw on a non-schema value', () => {
@@ -8,7 +8,7 @@ describe('lazy', function () {
 
   describe('mapper', () => {
     const value = 1;
-    let mapper: jest.Mock<MixedSchemaClass<any, AnyObject, undefined, ''>, []>;
+    let mapper: jest.Mock<MixedSchema<any, AnyObject, undefined, ''>, []>;
 
     beforeEach(() => {
       mapper = jest.fn(() => mixed());
diff --git a/test/mixed.ts b/test/mixed.ts
index 8d7cc1c..6c6eb69 100644
--- a/test/mixed.ts
+++ b/test/mixed.ts
@@ -7,6 +7,7 @@ import {
   object,
   reach,
   ref,
+  Schema,
   string,
   ValidationError,
 } from '../src';
@@ -38,6 +39,13 @@ global.YUP_USE_SYNC &&
   });
 
 describe('Mixed Types ', () => {
+  it('is not nullable by default', async () => {
+    let inst = mixed();
+
+    await expect(inst.isValid(null)).resolves.toBe(false);
+    await expect(inst.nullable().isValid(null)).resolves.toBe(true);
+  });
+
   it('cast should return a default when undefined', () => {
     let inst = mixed().default('hello');
 
@@ -218,9 +226,9 @@ describe('Mixed Types ', () => {
       'this is a required field',
     );
 
-    inst = inst.notRequired();
+    const next = inst.notRequired();
 
-    await expect(inst.isValid(undefined)).resolves.toBe(true);
+    await expect(next.isValid(undefined)).resolves.toBe(true);
   });
 
   // @ts-ignore
@@ -422,7 +430,7 @@ describe('Mixed Types ', () => {
       message: 'invalid',
       exclusive: true,
       name: 'max',
-      test: (v) => v < 5,
+      test: (v) => v! < 5,
     });
 
     expect(await inst.isValid(8)).toBe(false);
@@ -433,7 +441,7 @@ describe('Mixed Types ', () => {
           message: 'invalid',
           exclusive: true,
           name: 'max',
-          test: (v) => v < 10,
+          test: (v) => v! < 10,
         })
         .isValid(8),
     ).toBe(true);
@@ -627,11 +635,13 @@ describe('Mixed Types ', () => {
     });
 
     it('should have the correct number of tests', () => {
-      expect(reach(next, 'str').tests).toHaveLength(2);
+      expect((reach(next, 'str') as Schema).tests).toHaveLength(2);
     });
 
     it('should have the tests in the correct order', () => {
-      expect(reach(next, 'str').tests[0].OPTIONS.name).toBe('required');
+      expect((reach(next, 'str') as Schema).tests[0].OPTIONS?.name).toBe(
+        'required',
+      );
     });
 
     it('should validate correctly', async () => {
@@ -785,20 +795,20 @@ describe('Mixed Types ', () => {
       inst.validate('hello', { parent: { prop: 5 } } as any),
     ).resolves.toBeDefined();
 
-    inst = string().when('prop', {
+    const strInst = string().when('prop', {
       is: 5,
       then: (s) => s.required(),
       otherwise: (s) => s.min(4),
     });
 
     await expect(
-      inst.validate(undefined, { parent: { prop: 5 } } as any),
+      strInst.validate(undefined, { parent: { prop: 5 } } as any),
     ).rejects.toThrowError();
     await expect(
-      inst.validate('hello', { parent: { prop: 1 } } as any),
+      strInst.validate('hello', { parent: { prop: 1 } } as any),
     ).resolves.toBeDefined();
     await expect(
-      inst.validate('hel', { parent: { prop: 1 } } as any),
+      strInst.validate('hel', { parent: { prop: 1 } } as any),
     ).rejects.toThrowError();
   });
 
@@ -841,20 +851,20 @@ describe('Mixed Types ', () => {
       inst.validate('hello', { context: { prop: 5 } }),
     ).resolves.toBeDefined();
 
-    inst = string().when('$prop', {
+    const strInst = string().when('$prop', {
       is: (val: any) => val === 5,
       then: (s) => s.required(),
       otherwise: (s) => s.min(4),
     });
 
     await expect(
-      inst.validate(undefined, { context: { prop: 5 } }),
+      strInst.validate(undefined, { context: { prop: 5 } }),
     ).rejects.toThrowError();
     await expect(
-      inst.validate('hello', { context: { prop: 1 } }),
+      strInst.validate('hello', { context: { prop: 1 } }),
     ).resolves.toBeDefined();
     await expect(
-      inst.validate('hel', { context: { prop: 1 } }),
+      strInst.validate('hel', { context: { prop: 1 } }),
     ).rejects.toThrowError();
   });
 
diff --git a/test/types/types.ts b/test/types/types.ts
index e147c23..ac9783a 100644
--- a/test/types/types.ts
+++ b/test/types/types.ts
@@ -1,6 +1,15 @@
 /* eslint-disable @typescript-eslint/no-unused-expressions */
 /* eslint-disable no-unused-labels */
-import { array, number, string, date, ref, mixed, bool } from '../../src';
+import {
+  array,
+  number,
+  string,
+  date,
+  ref,
+  mixed,
+  bool,
+  reach,
+} from '../../src';
 import { create as tuple } from '../../src/tuple';
 import { create as lazy } from '../../src/Lazy';
 import ObjectSchema, { create as object } from '../../src/object';
@@ -106,6 +115,9 @@ Mixed: {
 
   // $ExpectType string | null | undefined
   mixed<string>().defined().cast('', { assert: 'ignore-optionality' });
+
+  // $ExpectType AnyPresentValue | null
+  mixed().defined().nullable().cast('');
 }
 
 Strings: {
@@ -972,3 +984,30 @@ TypeAssigning: {
     toJSON: mixed<() => any>().required(),
   });
 }
+
+reach: {
+  const obj = object({
+    string: string<'foo'>().defined(),
+    number: number().default(1),
+    removed: number().strip(),
+    ref: ref<'foo'>('string'),
+    nest: array(
+      object({
+        other: string(),
+      }),
+    ),
+    nullObject: object({
+      other: string(),
+    }).default(null),
+    lazy: lazy(() => number().defined()),
+  });
+
+  // $ExpectType ISchema<string | undefined, AnyObject, any, any> | Reference<string | undefined>
+  const _1 = reach(obj, 'nest[0].other' as const);
+
+  // $ExpectType Reference<{ other?: string | undefined; } | undefined> | ISchema<{ other?: string | undefined; } | undefined, AnyObject, any, any>
+  const _2 = reach(obj, 'nest[0]' as const);
+
+  // $ExpectType Reference<"foo"> | ISchema<"foo", AnyObject, any, any>
+  const _3 = reach(obj, 'ref');
+}