Skip to content

Commit

Permalink
feat: allow mixed schema to specify type check
Browse files Browse the repository at this point in the history
  • Loading branch information
jquense committed Dec 29, 2021
1 parent 25c4aa5 commit 3923039
Show file tree
Hide file tree
Showing 13 changed files with 147 additions and 62 deletions.
33 changes: 27 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
11 changes: 6 additions & 5 deletions src/array.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,12 @@ export default class ArraySchema<
innerType?: ISchema<T, TContext>;

constructor(type?: ISchema<T, TContext>) {
super({ type: 'array' });
super({
type: 'array',
check(v: any): v is NonNullable<TIn> {
return Array.isArray(v);
},
});

// `undefined` specifically means uninitialized, as opposed to
// "no subtype"
Expand All @@ -67,10 +72,6 @@ export default class ArraySchema<
});
}

protected _typeCheck(v: any): v is NonNullable<TIn> {
return Array.isArray(v);
}

private get _subType() {
return this.innerType;
}
Expand Down
15 changes: 8 additions & 7 deletions src/boolean.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,14 @@ export default class BooleanSchema<
TFlags extends Flags = '',
> extends BaseSchema<TType, TContext, TDefault, TFlags> {
constructor() {
super({ type: 'boolean' });
super({
type: 'boolean',
check(v: any): v is NonNullable<TType> {
if (v instanceof Boolean) v = v.valueOf();

return typeof v === 'boolean';
},
});

this.withMutation(() => {
this.transform(function (value) {
Expand All @@ -41,12 +48,6 @@ export default class BooleanSchema<
});
}

protected _typeCheck(v: any): v is NonNullable<TType> {
if (v instanceof Boolean) v = v.valueOf();

return typeof v === 'boolean';
}

isTrue(
message = locale.isValue,
): BooleanSchema<true | Optionals<TType>, TContext, TFlags> {
Expand Down
11 changes: 6 additions & 5 deletions src/date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TType> {
return isDate(v) && !isNaN(v.getTime());
},
});

this.withMutation(() => {
this.transform(function (value) {
Expand All @@ -52,10 +57,6 @@ export default class DateSchema<
});
}

protected _typeCheck(v: any): v is NonNullable<TType> {
return isDate(v) && !isNaN(v.getTime());
}

private prepareParam(
ref: unknown | Ref<Date>,
name: string,
Expand Down
14 changes: 12 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -38,7 +42,13 @@ function addMethod(schemaType: any, name: string, fn: any) {

export type AnyObjectSchema = ObjectSchema<any, any, any, any>;

export type { AnyObject, InferType, InferType as Asserts, AnySchema };
export type {
AnyObject,
InferType,
InferType as Asserts,
AnySchema,
MixedOptions,
};

export {
mixedCreate as mixed,
Expand Down
22 changes: 13 additions & 9 deletions src/locale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,20 +66,24 @@ export interface LocaleObject {
export let mixed: Required<MixedLocale> = {
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<StringLocale> = {
Expand Down
13 changes: 11 additions & 2 deletions src/mixed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,17 @@ const Mixed: typeof MixedSchema = BaseSchema as any;

export default Mixed;

export function create<TType = any>() {
return new Mixed<TType | undefined>();
export type TypeGuard<TType> = (value: any) => value is NonNullable<TType>;
export interface MixedOptions<TType> {
type?: string;
check?: TypeGuard<TType>;
}
export function create<TType = any>(
spec?: MixedOptions<TType> | TypeGuard<TType>,
) {
return new Mixed<TType | undefined>(
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;
15 changes: 8 additions & 7 deletions src/number.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,14 @@ export default class NumberSchema<
TFlags extends Flags = '',
> extends BaseSchema<TType, TContext, TDefault, TFlags> {
constructor() {
super({ type: 'number' });
super({
type: 'number',
check(value: any): value is NonNullable<TType> {
if (value instanceof Number) value = value.valueOf();

return typeof value === 'number' && !isNaN(value);
},
});

this.withMutation(() => {
this.transform(function (value) {
Expand All @@ -52,12 +59,6 @@ export default class NumberSchema<
});
}

protected _typeCheck(value: any): value is NonNullable<TType> {
if (value instanceof Number) value = value.valueOf();

return typeof value === 'number' && !isNaN(value);
}

min(min: number | Reference<number>, message = locale.min) {
return this.test({
message,
Expand Down
9 changes: 3 additions & 6 deletions src/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ export default class ObjectSchema<
constructor(spec?: Shape<TIn, TContext>) {
super({
type: 'object',
check(value): value is NonNullable<MakeKeysOptional<TIn>> {
return isObject(value) || typeof value === 'function';
},
});

this.withMutation(() => {
Expand All @@ -139,12 +142,6 @@ export default class ObjectSchema<
});
}

protected _typeCheck(
value: any,
): value is NonNullable<MakeKeysOptional<TIn>> {
return isObject(value) || typeof value === 'function';
}

protected _cast(_value: any, options: InternalOptions<TContext> = {}) {
let value = super._cast(_value, options);

Expand Down
13 changes: 7 additions & 6 deletions src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,10 @@ export type SchemaSpec<TDefault> = {
meta?: any;
};

export type SchemaOptions<TDefault> = {
export type SchemaOptions<TType, TDefault> = {
type?: string;
spec?: SchemaSpec<TDefault>;
check?: (value: any) => value is NonNullable<TType>;
};

export type AnySchema<
Expand Down Expand Up @@ -148,10 +149,11 @@ export default abstract class BaseSchema<
protected _blacklist = new ReferenceSet();

protected exclusiveTests: Record<string, boolean> = Object.create(null);
protected _typeCheck: (value: any) => value is NonNullable<TType>;

spec: SchemaSpec<any>;

constructor(options?: SchemaOptions<any>) {
constructor(options?: SchemaOptions<TType, any>) {
this.tests = [];
this.transforms = [];

Expand All @@ -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<TType> => true);

this.spec = {
strip: false,
Expand All @@ -181,10 +185,6 @@ export default abstract class BaseSchema<
return this.type;
}

protected _typeCheck(_value: any): _value is NonNullable<TType> {
return true;
}

clone(spec?: Partial<SchemaSpec<any>>): this {
if (this._mutate) {
if (spec) Object.assign(this.spec, spec);
Expand All @@ -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();
Expand Down
15 changes: 8 additions & 7 deletions src/string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,14 @@ export default class StringSchema<
TFlags extends Flags = '',
> extends BaseSchema<TType, TContext, TDefault, TFlags> {
constructor() {
super({ type: 'string' });
super({
type: 'string',
check(value): value is NonNullable<TType> {
if (value instanceof String) value = value.valueOf();

return typeof value === 'string';
},
});

this.withMutation(() => {
this.transform(function (value) {
Expand All @@ -72,12 +79,6 @@ export default class StringSchema<
});
}

protected _typeCheck(value: any): value is NonNullable<TType> {
if (value instanceof String) value = value.valueOf();

return typeof value === 'string';
}

protected _isPresent(value: any) {
return super._isPresent(value) && !!value.length;
}
Expand Down
29 changes: 29 additions & 0 deletions test/mixed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/,
Expand Down
9 changes: 9 additions & 0 deletions test/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,15 @@ Mixed: {

// $ExpectType "foo" | undefined
mixed<string>().notRequired().concat(string<'foo'>()).cast('');

// $ExpectType MixedSchema<string | undefined, AnyObject, undefined, "">
mixed((value): value is string => typeof value === 'string');

// $ExpectType MixedSchema<string | undefined, AnyObject, undefined, "">
mixed({
type: 'string',
check: (value): value is string => typeof value === 'string',
});
}

Strings: {
Expand Down

0 comments on commit 3923039

Please sign in to comment.