Skip to content

Commit 3923039

Browse files
committed
feat: allow mixed schema to specify type check
1 parent 25c4aa5 commit 3923039

13 files changed

+147
-62
lines changed

README.md

+27-6
Original file line numberDiff line numberDiff line change
@@ -557,14 +557,35 @@ Thrown on failed validations, with the following properties
557557

558558
### mixed
559559

560-
Creates a schema that matches all types. All types inherit from this base type
560+
Creates a schema that matches all types. All types inherit from this base type.
561561

562-
```js
563-
let schema = yup.mixed();
562+
```ts
563+
import { mixed } from 'yup';
564564

565-
schema.isValid(undefined, function (valid) {
566-
valid; // => true
567-
});
565+
let schema = mixed();
566+
567+
schema.validateSync('string'); // 'string';
568+
569+
schema.validateSync(1); // 1;
570+
571+
schema.validateSync(new Date()); // Date;
572+
```
573+
574+
Custom types can be implemented by passing a type check function:
575+
576+
```ts
577+
import { mixed } from 'yup';
578+
579+
let objectIdSchema = yup
580+
.mixed((input): input is ObjectId => input instanceof ObjectId)
581+
.transform((value: any, input, ctx) => {
582+
if (ctx.typeCheck(value)) return value;
583+
return new ObjectId(value);
584+
});
585+
586+
await objectIdSchema.validate(ObjectId('507f1f77bcf86cd799439011')); // ObjectId("507f1f77bcf86cd799439011")
587+
588+
await objectIdSchema.validate('507f1f77bcf86cd799439011'); // ObjectId("507f1f77bcf86cd799439011")
568589
```
569590

570591
#### `mixed.clone(): Schema`

src/array.ts

+6-5
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,12 @@ export default class ArraySchema<
4747
innerType?: ISchema<T, TContext>;
4848

4949
constructor(type?: ISchema<T, TContext>) {
50-
super({ type: 'array' });
50+
super({
51+
type: 'array',
52+
check(v: any): v is NonNullable<TIn> {
53+
return Array.isArray(v);
54+
},
55+
});
5156

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

70-
protected _typeCheck(v: any): v is NonNullable<TIn> {
71-
return Array.isArray(v);
72-
}
73-
7475
private get _subType() {
7576
return this.innerType;
7677
}

src/boolean.ts

+8-7
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,14 @@ export default class BooleanSchema<
2828
TFlags extends Flags = '',
2929
> extends BaseSchema<TType, TContext, TDefault, TFlags> {
3030
constructor() {
31-
super({ type: 'boolean' });
31+
super({
32+
type: 'boolean',
33+
check(v: any): v is NonNullable<TType> {
34+
if (v instanceof Boolean) v = v.valueOf();
35+
36+
return typeof v === 'boolean';
37+
},
38+
});
3239

3340
this.withMutation(() => {
3441
this.transform(function (value) {
@@ -41,12 +48,6 @@ export default class BooleanSchema<
4148
});
4249
}
4350

44-
protected _typeCheck(v: any): v is NonNullable<TType> {
45-
if (v instanceof Boolean) v = v.valueOf();
46-
47-
return typeof v === 'boolean';
48-
}
49-
5051
isTrue(
5152
message = locale.isValue,
5253
): BooleanSchema<true | Optionals<TType>, TContext, TFlags> {

src/date.ts

+6-5
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,12 @@ export default class DateSchema<
3838
static INVALID_DATE = invalidDate;
3939

4040
constructor() {
41-
super({ type: 'date' });
41+
super({
42+
type: 'date',
43+
check(v: any): v is NonNullable<TType> {
44+
return isDate(v) && !isNaN(v.getTime());
45+
},
46+
});
4247

4348
this.withMutation(() => {
4449
this.transform(function (value) {
@@ -52,10 +57,6 @@ export default class DateSchema<
5257
});
5358
}
5459

55-
protected _typeCheck(v: any): v is NonNullable<TType> {
56-
return isDate(v) && !isNaN(v.getTime());
57-
}
58-
5960
private prepareParam(
6061
ref: unknown | Ref<Date>,
6162
name: string,

src/index.ts

+12-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import Mixed, { create as mixedCreate, MixedSchema } from './mixed';
1+
import Mixed, {
2+
create as mixedCreate,
3+
MixedSchema,
4+
MixedOptions,
5+
} from './mixed';
26
import BooleanSchema, { create as boolCreate } from './boolean';
37
import StringSchema, { create as stringCreate } from './string';
48
import NumberSchema, { create as numberCreate } from './number';
@@ -38,7 +42,13 @@ function addMethod(schemaType: any, name: string, fn: any) {
3842

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

41-
export type { AnyObject, InferType, InferType as Asserts, AnySchema };
45+
export type {
46+
AnyObject,
47+
InferType,
48+
InferType as Asserts,
49+
AnySchema,
50+
MixedOptions,
51+
};
4252

4353
export {
4454
mixedCreate as mixed,

src/locale.ts

+13-9
Original file line numberDiff line numberDiff line change
@@ -66,20 +66,24 @@ export interface LocaleObject {
6666
export let mixed: Required<MixedLocale> = {
6767
default: '${path} is invalid',
6868
required: '${path} is a required field',
69+
defined: '${path} must be defined',
70+
notNull: '${path} cannot be null',
6971
oneOf: '${path} must be one of the following values: ${values}',
7072
notOneOf: '${path} must not be one of the following values: ${values}',
7173
notType: ({ path, type, value, originalValue }) => {
72-
let isCast = originalValue != null && originalValue !== value;
73-
return (
74-
`${path} must be a \`${type}\` type, ` +
75-
`but the final value was: \`${printValue(value, true)}\`` +
76-
(isCast
74+
const castMsg =
75+
originalValue != null && originalValue !== value
7776
? ` (cast from the value \`${printValue(originalValue, true)}\`).`
78-
: '.')
79-
);
77+
: '.';
78+
79+
return type !== 'mixed'
80+
? `${path} must be a \`${type}\` type, ` +
81+
`but the final value was: \`${printValue(value, true)}\`` +
82+
castMsg
83+
: `${path} must match the configured type. ` +
84+
`The validated value was: \`${printValue(value, true)}\`` +
85+
castMsg;
8086
},
81-
defined: '${path} must be defined',
82-
notNull: '${path} cannot be null',
8387
};
8488

8589
export let string: Required<StringLocale> = {

src/mixed.ts

+11-2
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,17 @@ const Mixed: typeof MixedSchema = BaseSchema as any;
5656

5757
export default Mixed;
5858

59-
export function create<TType = any>() {
60-
return new Mixed<TType | undefined>();
59+
export type TypeGuard<TType> = (value: any) => value is NonNullable<TType>;
60+
export interface MixedOptions<TType> {
61+
type?: string;
62+
check?: TypeGuard<TType>;
63+
}
64+
export function create<TType = any>(
65+
spec?: MixedOptions<TType> | TypeGuard<TType>,
66+
) {
67+
return new Mixed<TType | undefined>(
68+
typeof spec === 'function' ? { check: spec } : spec,
69+
);
6170
}
6271
// XXX: this is using the Base schema so that `addMethod(mixed)` works as a base class
6372
create.prototype = Mixed.prototype;

src/number.ts

+8-7
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,14 @@ export default class NumberSchema<
3232
TFlags extends Flags = '',
3333
> extends BaseSchema<TType, TContext, TDefault, TFlags> {
3434
constructor() {
35-
super({ type: 'number' });
35+
super({
36+
type: 'number',
37+
check(value: any): value is NonNullable<TType> {
38+
if (value instanceof Number) value = value.valueOf();
39+
40+
return typeof value === 'number' && !isNaN(value);
41+
},
42+
});
3643

3744
this.withMutation(() => {
3845
this.transform(function (value) {
@@ -52,12 +59,6 @@ export default class NumberSchema<
5259
});
5360
}
5461

55-
protected _typeCheck(value: any): value is NonNullable<TType> {
56-
if (value instanceof Number) value = value.valueOf();
57-
58-
return typeof value === 'number' && !isNaN(value);
59-
}
60-
6162
min(min: number | Reference<number>, message = locale.min) {
6263
return this.test({
6364
message,

src/object.ts

+3-6
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,9 @@ export default class ObjectSchema<
118118
constructor(spec?: Shape<TIn, TContext>) {
119119
super({
120120
type: 'object',
121+
check(value): value is NonNullable<MakeKeysOptional<TIn>> {
122+
return isObject(value) || typeof value === 'function';
123+
},
121124
});
122125

123126
this.withMutation(() => {
@@ -139,12 +142,6 @@ export default class ObjectSchema<
139142
});
140143
}
141144

142-
protected _typeCheck(
143-
value: any,
144-
): value is NonNullable<MakeKeysOptional<TIn>> {
145-
return isObject(value) || typeof value === 'function';
146-
}
147-
148145
protected _cast(_value: any, options: InternalOptions<TContext> = {}) {
149146
let value = super._cast(_value, options);
150147

src/schema.ts

+7-6
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,10 @@ export type SchemaSpec<TDefault> = {
5353
meta?: any;
5454
};
5555

56-
export type SchemaOptions<TDefault> = {
56+
export type SchemaOptions<TType, TDefault> = {
5757
type?: string;
5858
spec?: SchemaSpec<TDefault>;
59+
check?: (value: any) => value is NonNullable<TType>;
5960
};
6061

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

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

152154
spec: SchemaSpec<any>;
153155

154-
constructor(options?: SchemaOptions<any>) {
156+
constructor(options?: SchemaOptions<TType, any>) {
155157
this.tests = [];
156158
this.transforms = [];
157159

@@ -160,6 +162,8 @@ export default abstract class BaseSchema<
160162
});
161163

162164
this.type = options?.type || ('mixed' as const);
165+
this._typeCheck =
166+
options?.check || ((v: any): v is NonNullable<TType> => true);
163167

164168
this.spec = {
165169
strip: false,
@@ -181,10 +185,6 @@ export default abstract class BaseSchema<
181185
return this.type;
182186
}
183187

184-
protected _typeCheck(_value: any): _value is NonNullable<TType> {
185-
return true;
186-
}
187-
188188
clone(spec?: Partial<SchemaSpec<any>>): this {
189189
if (this._mutate) {
190190
if (spec) Object.assign(this.spec, spec);
@@ -197,6 +197,7 @@ export default abstract class BaseSchema<
197197

198198
// @ts-expect-error this is readonly
199199
next.type = this.type;
200+
next._typeCheck = this._typeCheck;
200201

201202
next._whitelist = this._whitelist.clone();
202203
next._blacklist = this._blacklist.clone();

src/string.ts

+8-7
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,14 @@ export default class StringSchema<
5555
TFlags extends Flags = '',
5656
> extends BaseSchema<TType, TContext, TDefault, TFlags> {
5757
constructor() {
58-
super({ type: 'string' });
58+
super({
59+
type: 'string',
60+
check(value): value is NonNullable<TType> {
61+
if (value instanceof String) value = value.valueOf();
62+
63+
return typeof value === 'string';
64+
},
65+
});
5966

6067
this.withMutation(() => {
6168
this.transform(function (value) {
@@ -72,12 +79,6 @@ export default class StringSchema<
7279
});
7380
}
7481

75-
protected _typeCheck(value: any): value is NonNullable<TType> {
76-
if (value instanceof String) value = value.valueOf();
77-
78-
return typeof value === 'string';
79-
}
80-
8182
protected _isPresent(value: any) {
8283
return super._isPresent(value) && !!value.length;
8384
}

test/mixed.ts

+29
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,35 @@ describe('Mixed Types ', () => {
5858
expect(inst.getDefault({ context: { foo: 'greet' } })).toBe('hi');
5959
});
6060

61+
it('should use provided check', async () => {
62+
let schema = mixed((v): v is string => typeof v === 'string');
63+
64+
// @ts-expect-error narrowed type
65+
schema.default(1);
66+
67+
expect(schema.isType(1)).toBe(false);
68+
expect(schema.isType('foo')).toBe(true);
69+
70+
await expect(schema.validate(1)).rejects.toThrowError(
71+
/this must match the configured type\. The validated value was: `1`/,
72+
);
73+
74+
schema = mixed({
75+
type: 'string',
76+
check: (v): v is string => typeof v === 'string',
77+
});
78+
79+
// @ts-expect-error narrowed type
80+
schema.default(1);
81+
82+
expect(schema.isType(1)).toBe(false);
83+
expect(schema.isType('foo')).toBe(true);
84+
85+
await expect(schema.validate(1)).rejects.toThrowError(
86+
/this must be a `string` type/,
87+
);
88+
});
89+
6190
it('should warn about null types', async () => {
6291
await expect(string().strict().validate(null)).rejects.toThrowError(
6392
/this cannot be null/,

test/types/types.ts

+9
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,15 @@ Mixed: {
9090

9191
// $ExpectType "foo" | undefined
9292
mixed<string>().notRequired().concat(string<'foo'>()).cast('');
93+
94+
// $ExpectType MixedSchema<string | undefined, AnyObject, undefined, "">
95+
mixed((value): value is string => typeof value === 'string');
96+
97+
// $ExpectType MixedSchema<string | undefined, AnyObject, undefined, "">
98+
mixed({
99+
type: 'string',
100+
check: (value): value is string => typeof value === 'string',
101+
});
93102
}
94103

95104
Strings: {

0 commit comments

Comments
 (0)