diff --git a/README.md b/README.md index efd0c983e..f2367b760 100644 --- a/README.md +++ b/README.md @@ -557,14 +557,35 @@ Thrown on failed validations, with the following properties ### mixed -Creates a schema that matches all types. All types inherit from this base type +Creates a schema that matches all types. All types inherit from this base type. -```js -let schema = yup.mixed(); +```ts +import { mixed } from 'yup'; -schema.isValid(undefined, function (valid) { - valid; // => true -}); +let schema = mixed(); + +schema.validateSync('string'); // 'string'; + +schema.validateSync(1); // 1; + +schema.validateSync(new Date()); // Date; +``` + +Custom types can be implemented by passing a type check function: + +```ts +import { mixed } from 'yup'; + +let objectIdSchema = yup + .mixed((input): input is ObjectId => input instanceof ObjectId) + .transform((value: any, input, ctx) => { + if (ctx.typeCheck(value)) return value; + return new ObjectId(value); + }); + +await objectIdSchema.validate(ObjectId('507f1f77bcf86cd799439011')); // ObjectId("507f1f77bcf86cd799439011") + +await objectIdSchema.validate('507f1f77bcf86cd799439011'); // ObjectId("507f1f77bcf86cd799439011") ``` #### `mixed.clone(): Schema` diff --git a/src/array.ts b/src/array.ts index 1a82a86f9..e9afc96e7 100644 --- a/src/array.ts +++ b/src/array.ts @@ -47,7 +47,12 @@ export default class ArraySchema< innerType?: ISchema; constructor(type?: ISchema) { - super({ type: 'array' }); + super({ + type: 'array', + check(v: any): v is NonNullable { + return Array.isArray(v); + }, + }); // `undefined` specifically means uninitialized, as opposed to // "no subtype" @@ -67,10 +72,6 @@ export default class ArraySchema< }); } - protected _typeCheck(v: any): v is NonNullable { - return Array.isArray(v); - } - private get _subType() { return this.innerType; } diff --git a/src/boolean.ts b/src/boolean.ts index 764dad7b6..ab3448498 100644 --- a/src/boolean.ts +++ b/src/boolean.ts @@ -28,7 +28,14 @@ export default class BooleanSchema< TFlags extends Flags = '', > extends BaseSchema { constructor() { - super({ type: 'boolean' }); + super({ + type: 'boolean', + check(v: any): v is NonNullable { + if (v instanceof Boolean) v = v.valueOf(); + + return typeof v === 'boolean'; + }, + }); this.withMutation(() => { this.transform(function (value) { @@ -41,12 +48,6 @@ export default class BooleanSchema< }); } - protected _typeCheck(v: any): v is NonNullable { - if (v instanceof Boolean) v = v.valueOf(); - - return typeof v === 'boolean'; - } - isTrue( message = locale.isValue, ): BooleanSchema, TContext, TFlags> { diff --git a/src/date.ts b/src/date.ts index c1b068a9f..e2192eaf5 100644 --- a/src/date.ts +++ b/src/date.ts @@ -38,7 +38,12 @@ export default class DateSchema< static INVALID_DATE = invalidDate; constructor() { - super({ type: 'date' }); + super({ + type: 'date', + check(v: any): v is NonNullable { + return isDate(v) && !isNaN(v.getTime()); + }, + }); this.withMutation(() => { this.transform(function (value) { @@ -52,10 +57,6 @@ export default class DateSchema< }); } - protected _typeCheck(v: any): v is NonNullable { - return isDate(v) && !isNaN(v.getTime()); - } - private prepareParam( ref: unknown | Ref, name: string, diff --git a/src/index.ts b/src/index.ts index e78a0a122..23ef75c08 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,8 @@ -import Mixed, { create as mixedCreate, MixedSchema } from './mixed'; +import Mixed, { + create as mixedCreate, + MixedSchema, + MixedOptions, +} from './mixed'; import BooleanSchema, { create as boolCreate } from './boolean'; import StringSchema, { create as stringCreate } from './string'; import NumberSchema, { create as numberCreate } from './number'; @@ -38,7 +42,13 @@ function addMethod(schemaType: any, name: string, fn: any) { export type AnyObjectSchema = ObjectSchema; -export type { AnyObject, InferType, InferType as Asserts, AnySchema }; +export type { + AnyObject, + InferType, + InferType as Asserts, + AnySchema, + MixedOptions, +}; export { mixedCreate as mixed, diff --git a/src/locale.ts b/src/locale.ts index d724804ea..25eee1562 100644 --- a/src/locale.ts +++ b/src/locale.ts @@ -66,20 +66,24 @@ export interface LocaleObject { export let mixed: Required = { default: '${path} is invalid', required: '${path} is a required field', + defined: '${path} must be defined', + notNull: '${path} cannot be null', oneOf: '${path} must be one of the following values: ${values}', notOneOf: '${path} must not be one of the following values: ${values}', notType: ({ path, type, value, originalValue }) => { - let isCast = originalValue != null && originalValue !== value; - return ( - `${path} must be a \`${type}\` type, ` + - `but the final value was: \`${printValue(value, true)}\`` + - (isCast + const castMsg = + originalValue != null && originalValue !== value ? ` (cast from the value \`${printValue(originalValue, true)}\`).` - : '.') - ); + : '.'; + + return type !== 'mixed' + ? `${path} must be a \`${type}\` type, ` + + `but the final value was: \`${printValue(value, true)}\`` + + castMsg + : `${path} must match the configured type. ` + + `The validated value was: \`${printValue(value, true)}\`` + + castMsg; }, - defined: '${path} must be defined', - notNull: '${path} cannot be null', }; export let string: Required = { diff --git a/src/mixed.ts b/src/mixed.ts index aac0e496a..b93f62779 100644 --- a/src/mixed.ts +++ b/src/mixed.ts @@ -56,8 +56,17 @@ const Mixed: typeof MixedSchema = BaseSchema as any; export default Mixed; -export function create() { - return new Mixed(); +export type TypeGuard = (value: any) => value is NonNullable; +export interface MixedOptions { + type?: string; + check?: TypeGuard; +} +export function create( + spec?: MixedOptions | TypeGuard, +) { + return new Mixed( + typeof spec === 'function' ? { check: spec } : spec, + ); } // XXX: this is using the Base schema so that `addMethod(mixed)` works as a base class create.prototype = Mixed.prototype; diff --git a/src/number.ts b/src/number.ts index fda598674..7ef4597df 100644 --- a/src/number.ts +++ b/src/number.ts @@ -32,7 +32,14 @@ export default class NumberSchema< TFlags extends Flags = '', > extends BaseSchema { constructor() { - super({ type: 'number' }); + super({ + type: 'number', + check(value: any): value is NonNullable { + if (value instanceof Number) value = value.valueOf(); + + return typeof value === 'number' && !isNaN(value); + }, + }); this.withMutation(() => { this.transform(function (value) { @@ -52,12 +59,6 @@ export default class NumberSchema< }); } - protected _typeCheck(value: any): value is NonNullable { - if (value instanceof Number) value = value.valueOf(); - - return typeof value === 'number' && !isNaN(value); - } - min(min: number | Reference, message = locale.min) { return this.test({ message, diff --git a/src/object.ts b/src/object.ts index 207cf1796..7065f82f9 100644 --- a/src/object.ts +++ b/src/object.ts @@ -118,6 +118,9 @@ export default class ObjectSchema< constructor(spec?: Shape) { super({ type: 'object', + check(value): value is NonNullable> { + return isObject(value) || typeof value === 'function'; + }, }); this.withMutation(() => { @@ -139,12 +142,6 @@ export default class ObjectSchema< }); } - protected _typeCheck( - value: any, - ): value is NonNullable> { - return isObject(value) || typeof value === 'function'; - } - protected _cast(_value: any, options: InternalOptions = {}) { let value = super._cast(_value, options); diff --git a/src/schema.ts b/src/schema.ts index e9cb37aee..ae65debcf 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -53,9 +53,10 @@ export type SchemaSpec = { meta?: any; }; -export type SchemaOptions = { +export type SchemaOptions = { type?: string; spec?: SchemaSpec; + check?: (value: any) => value is NonNullable; }; export type AnySchema< @@ -148,10 +149,11 @@ export default abstract class BaseSchema< protected _blacklist = new ReferenceSet(); protected exclusiveTests: Record = Object.create(null); + protected _typeCheck: (value: any) => value is NonNullable; spec: SchemaSpec; - constructor(options?: SchemaOptions) { + constructor(options?: SchemaOptions) { this.tests = []; this.transforms = []; @@ -160,6 +162,8 @@ export default abstract class BaseSchema< }); this.type = options?.type || ('mixed' as const); + this._typeCheck = + options?.check || ((v: any): v is NonNullable => true); this.spec = { strip: false, @@ -181,10 +185,6 @@ export default abstract class BaseSchema< return this.type; } - protected _typeCheck(_value: any): _value is NonNullable { - return true; - } - clone(spec?: Partial>): this { if (this._mutate) { if (spec) Object.assign(this.spec, spec); @@ -197,6 +197,7 @@ export default abstract class BaseSchema< // @ts-expect-error this is readonly next.type = this.type; + next._typeCheck = this._typeCheck; next._whitelist = this._whitelist.clone(); next._blacklist = this._blacklist.clone(); diff --git a/src/string.ts b/src/string.ts index 7b74df035..e8766fc37 100644 --- a/src/string.ts +++ b/src/string.ts @@ -55,7 +55,14 @@ export default class StringSchema< TFlags extends Flags = '', > extends BaseSchema { constructor() { - super({ type: 'string' }); + super({ + type: 'string', + check(value): value is NonNullable { + if (value instanceof String) value = value.valueOf(); + + return typeof value === 'string'; + }, + }); this.withMutation(() => { this.transform(function (value) { @@ -72,12 +79,6 @@ export default class StringSchema< }); } - protected _typeCheck(value: any): value is NonNullable { - if (value instanceof String) value = value.valueOf(); - - return typeof value === 'string'; - } - protected _isPresent(value: any) { return super._isPresent(value) && !!value.length; } diff --git a/test/mixed.ts b/test/mixed.ts index 784f396ff..5428da545 100644 --- a/test/mixed.ts +++ b/test/mixed.ts @@ -58,6 +58,35 @@ describe('Mixed Types ', () => { expect(inst.getDefault({ context: { foo: 'greet' } })).toBe('hi'); }); + it('should use provided check', async () => { + let schema = mixed((v): v is string => typeof v === 'string'); + + // @ts-expect-error narrowed type + schema.default(1); + + expect(schema.isType(1)).toBe(false); + expect(schema.isType('foo')).toBe(true); + + await expect(schema.validate(1)).rejects.toThrowError( + /this must match the configured type\. The validated value was: `1`/, + ); + + schema = mixed({ + type: 'string', + check: (v): v is string => typeof v === 'string', + }); + + // @ts-expect-error narrowed type + schema.default(1); + + expect(schema.isType(1)).toBe(false); + expect(schema.isType('foo')).toBe(true); + + await expect(schema.validate(1)).rejects.toThrowError( + /this must be a `string` type/, + ); + }); + 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 5d7e7070d..63ea70bad 100644 --- a/test/types/types.ts +++ b/test/types/types.ts @@ -90,6 +90,15 @@ Mixed: { // $ExpectType "foo" | undefined mixed().notRequired().concat(string<'foo'>()).cast(''); + + // $ExpectType MixedSchema + mixed((value): value is string => typeof value === 'string'); + + // $ExpectType MixedSchema + mixed({ + type: 'string', + check: (value): value is string => typeof value === 'string', + }); } Strings: {