Repository: jquense/yup
Pull request #1749 (2022-08-19 16:51:44)

feat: add cast nullability migration path.
Adds a `cast` option to not throw on optionality mismatches after casting. This is being added to support migration from pre-v1 where cases like `string().nullable().required()` are supported. This case is generally used to handle parsing server side "default" values to feed into a form. Initial values may be null or missing but require filling in by a user before submitting the value back to server. 

Going forward the approved way to handle these cases is with multiple schema using conditional when's to set `nullable` or using `schema.deepPartial()` to create an optional version of itself.


Patch:
diff --git a/src/Lazy.ts b/src/Lazy.ts
index 53d64de..6afc3c5 100644
--- a/src/Lazy.ts
+++ b/src/Lazy.ts
@@ -8,6 +8,7 @@ import type {
 import type { ResolveOptions } from './Condition';
 
 import type {
+  CastOptionalityOptions,
   CastOptions,
   SchemaFieldDescription,
   SchemaLazyDescription,
@@ -88,8 +89,16 @@ class Lazy<T, TContext = AnyObject, TFlags extends Flags = any>
     return this._resolve(options.value, options);
   }
 
-  cast(value: any, options?: CastOptions<TContext>): T {
-    return this._resolve(value, options).cast(value, options);
+  cast(value: any, options?: CastOptions<TContext>): T;
+  cast(
+    value: any,
+    options?: CastOptionalityOptions<TContext>,
+  ): T | null | undefined;
+  cast(
+    value: any,
+    options?: CastOptions<TContext> | CastOptionalityOptions<TContext>,
+  ): any {
+    return this._resolve(value, options).cast(value, options as any);
   }
 
   asNestedTest(options: NestedTestConfig) {
diff --git a/src/schema.ts b/src/schema.ts
index a471c59..d2a0b26 100644
--- a/src/schema.ts
+++ b/src/schema.ts
@@ -69,6 +69,17 @@ export interface CastOptions<C = {}> {
   path?: string;
 }
 
+export interface CastOptionalityOptions<C = {}>
+  extends Omit<CastOptions<C>, 'assert'> {
+  /**
+   * Whether or not to throw TypeErrors if casting fails to produce a valid type.
+   * defaults to `true`. The `'ignore-optionality'` options is provided as a migration
+   * path from pre-v1 where `schema.nullable().required()` was allowed. When provided
+   * cast will only throw for values that are the wrong type *not* including `null` and `undefined`
+   */
+  assert: 'ignore-optionality';
+}
+
 export type RunTest = (
   opts: TestOptions,
   panic: PanicCallback,
@@ -328,19 +339,33 @@ export default abstract class Schema<
   /**
    * Run the configured transform pipeline over an input value.
    */
-  cast(value: any, options: CastOptions<TContext> = {}): this['__outputType'] {
+  cast(value: any, options?: CastOptions<TContext>): this['__outputType'];
+  cast(
+    value: any,
+    options: CastOptionalityOptions<TContext>,
+  ): this['__outputType'] | null | undefined;
+  cast(
+    value: any,
+    options: CastOptions<TContext> | CastOptionalityOptions<TContext> = {},
+  ): this['__outputType'] {
     let resolvedSchema = this.resolve({
       value,
       ...options,
       // parent: options.parent,
       // context: options.context,
     });
+    let allowOptionality = options.assert === 'ignore-optionality';
 
-    let result = resolvedSchema._cast(value, options);
+    let result = resolvedSchema._cast(value, options as any);
 
     if (options.assert !== false && !resolvedSchema.isType(result)) {
+      if (allowOptionality && isAbsent(result)) {
+        return result as any;
+      }
+
       let formattedValue = printValue(value);
       let formattedResult = printValue(result);
+
       throw new TypeError(
         `The value of ${
           options.path || 'field'
@@ -523,8 +548,7 @@ export default abstract class Schema<
   validate(
     value: any,
     options?: ValidateOptions<TContext>,
-  ): Promise<this['__outputType']>;
-  validate(value: any, options?: ValidateOptions<TContext>): any {
+  ): Promise<this['__outputType']> {
     let schema = this.resolve({ ...options, value });
 
     return new Promise((resolve, reject) =>
@@ -537,16 +561,12 @@ export default abstract class Schema<
         },
         (errors, validated) => {
           if (errors.length) reject(new ValidationError(errors!, validated));
-          else resolve(validated);
+          else resolve(validated as this['__outputType']);
         },
       ),
     );
   }
 
-  validateSync(
-    value: any,
-    options?: ValidateOptions<TContext>,
-  ): this['__outputType'];
   validateSync(
     value: any,
     options?: ValidateOptions<TContext>,
diff --git a/src/types.ts b/src/types.ts
index 592c93a..4000bbc 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,6 +1,7 @@
 import type { ResolveOptions } from './Condition';
 import type {
   AnySchema,
+  CastOptionalityOptions,
   CastOptions,
   SchemaFieldDescription,
   SchemaSpec,
@@ -18,6 +19,8 @@ export interface ISchema<T, C = AnyObject, F extends Flags = any, D = any> {
   __default: D;
 
   cast(value: any, options?: CastOptions<C>): T;
+  cast(value: any, options: CastOptionalityOptions<C>): T | null | undefined;
+
   validate(value: any, options?: ValidateOptions<C>): Promise<T>;
 
   asNestedTest(config: NestedTestConfig): Test;


Test patch:
diff --git a/test/mixed.ts b/test/mixed.ts
index d4e4789..1521c79 100644
--- a/test/mixed.ts
+++ b/test/mixed.ts
@@ -87,6 +87,16 @@ describe('Mixed Types ', () => {
     );
   });
 
+  it('should allow missing values with the "ignore-optionality" option', () => {
+    expect(
+      string().required().cast(null, { assert: 'ignore-optionality' }),
+    ).toBe(null);
+
+    expect(
+      string().required().cast(undefined, { assert: 'ignore-optionality' }),
+    ).toBe(undefined);
+  });
+
   it('should warn about null types', async () => {
     await expect(string().strict().validate(null)).rejects.toThrowError(
       /this cannot be null/,
diff --git a/test/types/types.ts b/test/types/types.ts
index de50203..22fe269 100644
--- a/test/types/types.ts
+++ b/test/types/types.ts
@@ -100,6 +100,12 @@ Mixed: {
     type: 'string',
     check: (value): value is string => typeof value === 'string',
   });
+
+  // $ExpectType string
+  mixed<string>().defined().cast('', { assert: true });
+
+  // $ExpectType string | null | undefined
+  mixed<string>().defined().cast('', { assert: 'ignore-optionality' });
 }
 
 Strings: {