Repository: jquense/yup
Pull request #1748 (2022-08-19 16:07:49)

feat: better Lazy types and deepPartial fixes
BREAKING CHANGE: The types for Lazy have changes a bit, it's unlikely that this affects anyone but it is technically a breaking change.

this also fixes: #1144 


Patch:
diff --git a/src/Lazy.ts b/src/Lazy.ts
index 1946966..53d64de 100644
--- a/src/Lazy.ts
+++ b/src/Lazy.ts
@@ -13,33 +13,27 @@ import type {
   SchemaLazyDescription,
 } from './schema';
 import { Flags } from './util/types';
-import { Schema } from '.';
+import { InferType, Schema } from '.';
 
 export type LazyBuilder<
-  T,
+  TSchema extends ISchema<TContext>,
   TContext = AnyObject,
-  TDefault = any,
-  TFlags extends Flags = any,
-> = (
-  value: any,
-  options: ResolveOptions,
-) => ISchema<T, TContext, TFlags, TDefault>;
+> = (value: any, options: ResolveOptions) => TSchema;
 
 export function create<
-  T,
+  TSchema extends ISchema<any, TContext>,
   TContext = AnyObject,
-  TFlags extends Flags = any,
-  TDefault = any,
->(builder: LazyBuilder<T, TContext, TDefault, TFlags>) {
-  return new Lazy<T, TContext, TDefault, TFlags>(builder);
+>(builder: (value: any, options: ResolveOptions<TContext>) => TSchema) {
+  return new Lazy<InferType<TSchema>, TContext>(builder);
 }
 
 export interface LazySpec {
   meta: Record<string, unknown> | undefined;
+  optional: boolean;
 }
 
-class Lazy<T, TContext = AnyObject, TDefault = any, TFlags extends Flags = any>
-  implements ISchema<T, TContext, TFlags, TDefault>
+class Lazy<T, TContext = AnyObject, TFlags extends Flags = any>
+  implements ISchema<T, TContext, TFlags, undefined>
 {
   type = 'lazy' as const;
 
@@ -48,37 +42,48 @@ class Lazy<T, TContext = AnyObject, TDefault = any, TFlags extends Flags = any>
   declare readonly __outputType: T;
   declare readonly __context: TContext;
   declare readonly __flags: TFlags;
-  declare readonly __default: TDefault;
+  declare readonly __default: undefined;
 
   spec: LazySpec;
 
-  constructor(private builder: LazyBuilder<T, TContext, TDefault, TFlags>) {
-    this.spec = { meta: undefined };
+  constructor(private builder: any) {
+    this.spec = { meta: undefined, optional: false };
   }
 
-  clone(): Lazy<T, TContext, TDefault, TFlags> {
-    const next = create(this.builder);
-    next.spec = { ...this.spec };
+  clone(spec?: Partial<LazySpec>): Lazy<T, TContext, TFlags> {
+    const next = new Lazy<T, TContext, TFlags>(this.builder);
+    next.spec = { ...this.spec, ...spec };
     return next;
   }
 
   private _resolve = (
     value: any,
     options: ResolveOptions<TContext> = {},
-  ): Schema<T, TContext, TDefault, TFlags> => {
+  ): Schema<T, TContext, undefined, TFlags> => {
     let schema = this.builder(value, options) as Schema<
       T,
       TContext,
-      TDefault,
+      undefined,
       TFlags
     >;
 
     if (!isSchema(schema))
       throw new TypeError('lazy() functions must return a valid schema');
 
+    if (this.spec.optional) schema = schema.optional();
+
     return schema.resolve(options);
   };
 
+  private optionality(optional: boolean) {
+    const next = this.clone({ optional });
+    return next;
+  }
+
+  optional(): Lazy<T | undefined, TContext, TFlags> {
+    return this.optionality(true);
+  }
+
   resolve(options: ResolveOptions<TContext>) {
     return this._resolve(options.value, options);
   }
@@ -129,7 +134,7 @@ class Lazy<T, TContext = AnyObject, TDefault = any, TFlags extends Flags = any>
   }
 
   meta(): Record<string, unknown> | undefined;
-  meta(obj: Record<string, unknown>): Lazy<T, TContext, TDefault, TFlags>;
+  meta(obj: Record<string, unknown>): Lazy<T, TContext, TFlags>;
   meta(...args: [Record<string, unknown>?]) {
     if (args.length === 0) return this.spec.meta;
 
diff --git a/src/index.ts b/src/index.ts
index 18e5c3b..4914a73 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -15,8 +15,16 @@ import { create as lazyCreate } from './Lazy';
 import ValidationError from './ValidationError';
 import reach, { getIn } from './util/reach';
 import isSchema from './util/isSchema';
-import setLocale from './setLocale';
-import Schema, { AnySchema } from './schema';
+import setLocale, { LocaleObject } from './setLocale';
+import Schema, {
+  AnySchema,
+  SchemaRefDescription,
+  SchemaInnerTypeDescription,
+  SchemaObjectDescription,
+  SchemaLazyDescription,
+  SchemaFieldDescription,
+  SchemaDescription,
+} from './schema';
 import type { InferType } from './types';
 
 function addMethod<T extends AnySchema>(
@@ -50,6 +58,13 @@ export type {
   AnySchema,
   MixedOptions,
   TypeGuard,
+  SchemaRefDescription,
+  SchemaInnerTypeDescription,
+  SchemaObjectDescription,
+  SchemaLazyDescription,
+  SchemaFieldDescription,
+  SchemaDescription,
+  LocaleObject,
 };
 
 export {
diff --git a/src/object.ts b/src/object.ts
index 542119f..55204dd 100644
--- a/src/object.ts
+++ b/src/object.ts
@@ -24,7 +24,6 @@ import type {
 import parseJson from './util/parseJson';
 import type { Test } from './util/createValidation';
 import type ValidationError from './ValidationError';
-
 export type { AnyObject };
 
 type MakeKeysOptional<T> = T extends AnyObject ? _<MakePartial<T>> : T;
@@ -37,6 +36,31 @@ export type ObjectSchemaSpec = SchemaSpec<any> & {
   noUnknown?: boolean;
 };
 
+function deepPartial(schema: any) {
+  if ('fields' in schema) {
+    const partial: any = {};
+    for (const [key, fieldSchema] of Object.entries(schema.fields)) {
+      partial[key] = deepPartial(fieldSchema);
+    }
+    return schema.setFields(partial);
+  }
+  if (schema.type === 'array') {
+    const nextArray = schema.optional();
+    if (nextArray.innerType)
+      nextArray.innerType = deepPartial(nextArray.innerType);
+    return nextArray;
+  }
+  if (schema.type === 'tuple') {
+    return schema
+      .optional()
+      .clone({ types: schema.spec.types.map(deepPartial) });
+  }
+  if ('optional' in schema) {
+    return schema.optional();
+  }
+  return schema;
+}
+
 const deepHas = (obj: any, p: string) => {
   const path = [...normalizePath(p)];
   if (path.length === 1) return path[0] in obj;
@@ -342,19 +366,18 @@ export default class ObjectSchema<
   partial() {
     const partial: any = {};
     for (const [key, schema] of Object.entries(this.fields)) {
-      partial[key] = schema instanceof Schema ? schema.optional() : schema;
+      partial[key] =
+        'optional' in schema && schema.optional instanceof Function
+          ? schema.optional()
+          : schema;
     }
 
     return this.setFields<Partial<TIn>, TDefault>(partial);
   }
 
-  deepPartial() {
-    const partial: any = {};
-    for (const [key, schema] of Object.entries(this.fields)) {
-      if (schema instanceof ObjectSchema) partial[key] = schema.deepPartial();
-      else partial[key] = schema instanceof Schema ? schema.optional() : schema;
-    }
-    return this.setFields<PartialDeep<TIn>, TDefault>(partial);
+  deepPartial(): ObjectSchema<PartialDeep<TIn>, TContext, TDefault, TFlags> {
+    const next = deepPartial(this);
+    return next;
   }
 
   pick<TKey extends keyof TIn>(keys: readonly TKey[]) {
diff --git a/src/setLocale.ts b/src/setLocale.ts
index fcdc7e4..ca5c4da 100644
--- a/src/setLocale.ts
+++ b/src/setLocale.ts
@@ -1,5 +1,7 @@
 import locale, { LocaleObject } from './locale';
 
+export type { LocaleObject };
+
 export default function setLocale(custom: LocaleObject) {
   Object.keys(custom).forEach((type) => {
     // @ts-ignore


Test patch:
diff --git a/test/object.ts b/test/object.ts
index 9526be9..77f3f2b 100644
--- a/test/object.ts
+++ b/test/object.ts
@@ -686,7 +686,7 @@ describe('Object types', () => {
     await expect(inst.partial().isValid({ name: '' })).resolves.toEqual(false);
   });
 
-  xit('deepPartial() should work', async () => {
+  it('deepPartial() should work', async () => {
     let inst = object({
       age: number().required(),
       name: string().required(),
@@ -694,6 +694,7 @@ describe('Object types', () => {
         object({
           name: string().required(),
           age: number().required(),
+          lazy: lazy(() => number().required()),
         }),
       ).defined(),
     });
@@ -703,17 +704,24 @@ describe('Object types', () => {
       inst.isValid({ age: 2, name: 'fs', contacts: [{}] }),
     ).resolves.toEqual(false);
 
-    await expect(inst.deepPartial().isValid({})).resolves.toEqual(true);
+    const instPartial = inst.deepPartial();
+
     await expect(
-      inst.deepPartial().validate({ contacts: [{}] }),
-    ).resolves.toEqual(true);
+      inst.validate({ age: 1, name: 'f', contacts: [{ name: 'f', age: 1 }] }),
+    ).rejects.toThrowError('contacts[0].lazy is a required field');
+
+    await expect(instPartial.isValid({})).resolves.toEqual(true);
+
+    await expect(instPartial.isValid({ contacts: [{}] })).resolves.toEqual(
+      true,
+    );
 
     await expect(
-      inst.deepPartial().isValid({ contacts: [{ age: null }] }),
+      instPartial.isValid({ contacts: [{ age: null }] }),
     ).resolves.toEqual(false);
 
     await expect(
-      inst.deepPartial().isValid({ contacts: [{ age: null }] }),
+      instPartial.isValid({ contacts: [{ lazy: null }] }),
     ).resolves.toEqual(false);
   });
 
diff --git a/test/types/types.ts b/test/types/types.ts
index 683d8d0..de50203 100644
--- a/test/types/types.ts
+++ b/test/types/types.ts
@@ -429,7 +429,15 @@ bool: {
 Lazy: {
   const l = lazy(() => string().default('asfasf'));
 
+  // $ExpectType string
   l.cast(null);
+
+  const l2 = lazy((v) =>
+    v ? string().default('asfasf') : number().required(),
+  );
+
+  // $ExpectType string | number
+  l2.cast(null);
 }
 
 Array: {
@@ -885,6 +893,7 @@ Object: {
     const schema = object({
       // age: number(),
       name: string().required(),
+      lazy: lazy(() => number().defined()),
       address: object()
         .shape({
           line1: string().required(),
@@ -896,18 +905,24 @@ Object: {
     const partial = schema.partial();
 
     // $ExpectType string | undefined
-    partial.validateSync({ age: '1' })!.name;
+    partial.validateSync({})!.name;
 
     // $ExpectType string
     partial.validateSync({})!.address!.line1;
 
+    // $ExpectType number | undefined
+    partial.validateSync({})!.lazy;
+
     const deepPartial = schema.deepPartial();
 
     // $ExpectType string | undefined
-    deepPartial.validateSync({ age: '1' })!.name;
+    deepPartial.validateSync({})!.name;
 
     // $ExpectType string | undefined
     deepPartial.validateSync({})!.address!.line1;
+
+    // $ExpectType number | undefined
+    deepPartial.validateSync({})!.lazy;
   }
 }