diff --git a/src/Lazy.ts b/src/Lazy.ts index 53d64de56..6afc3c539 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 return this._resolve(options.value, options); } - cast(value: any, options?: CastOptions): T { - return this._resolve(value, options).cast(value, options); + cast(value: any, options?: CastOptions): T; + cast( + value: any, + options?: CastOptionalityOptions, + ): T | null | undefined; + cast( + value: any, + options?: CastOptions | CastOptionalityOptions, + ): 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 a471c599d..d2a0b2640 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -69,6 +69,17 @@ export interface CastOptions { path?: string; } +export interface CastOptionalityOptions + extends Omit, '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 = {}): this['__outputType'] { + cast(value: any, options?: CastOptions): this['__outputType']; + cast( + value: any, + options: CastOptionalityOptions, + ): this['__outputType'] | null | undefined; + cast( + value: any, + options: CastOptions | CastOptionalityOptions = {}, + ): 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, - ): Promise; - validate(value: any, options?: ValidateOptions): any { + ): Promise { 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, - ): this['__outputType']; validateSync( value: any, options?: ValidateOptions, diff --git a/src/types.ts b/src/types.ts index 592c93aa8..4000bbca5 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 { __default: D; cast(value: any, options?: CastOptions): T; + cast(value: any, options: CastOptionalityOptions): T | null | undefined; + validate(value: any, options?: ValidateOptions): Promise; asNestedTest(config: NestedTestConfig): Test; diff --git a/test/mixed.ts b/test/mixed.ts index d4e4789c2..1521c79c6 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 de50203cc..22fe269d9 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().defined().cast('', { assert: true }); + + // $ExpectType string | null | undefined + mixed().defined().cast('', { assert: 'ignore-optionality' }); } Strings: {