Skip to content

Commit

Permalink
feat: stricter when types and API (#1542)
Browse files Browse the repository at this point in the history
BREAKING CHANGE: The function version of `when()` has been changed to make it easier to type. values are always passed as an array and schema, and options always the second and third argument. `this` is no longer set to the schema instance.  and all functions _must_ return a schema to be type safe

```diff
 string()
-   .when('other', function (other) => {
-      if (other) return this.required()
+   .when('other', ([other], schema) => {
+     return other ? schema.required() : schema
  })
```
  • Loading branch information
jquense committed Dec 28, 2021
1 parent fcaeb42 commit da74254
Show file tree
Hide file tree
Showing 8 changed files with 130 additions and 68 deletions.
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,7 @@ let schema = object({
loose: boolean(),
bar: string().when('loose', {
is: true,
otherwise: (s) => s.strict(),
otherwise: (schema) => schema.strict(),
}),
}),
),
Expand Down Expand Up @@ -634,7 +634,7 @@ await schema.isValid(42); // => false
await schema.isValid(new Date()); // => true
```

#### `mixed.when(keys: string | Array<string>, builder: object | (value, schema)=> Schema): Schema`
#### `mixed.when(keys: string | string[], builder: object | (values: any[], schema) => Schema): Schema`

Adjust the schema based on a sibling or sibling children fields. You can provide an object
literal where the key `is` is value or a matcher function, `then` provides the true schema and/or
Expand All @@ -652,8 +652,8 @@ let schema = object({
count: number()
.when('isBig', {
is: true, // alternatively: (val) => val == true
then: yup.number().min(5),
otherwise: yup.number().min(0),
then: (schema) => schema..min(5),
otherwise: (schema) => schema..min(0),
})
.when('$other', (other, schema) => (other === 4 ? schema.max(6) : schema)),
});
Expand All @@ -669,8 +669,8 @@ let schema = object({
isBig: boolean(),
count: number().when(['isBig', 'isSpecial'], {
is: true, // alternatively: (isBig, isSpecial) => isBig && isSpecial
then: yup.number().min(5),
otherwise: yup.number().min(0),
then: (schema) => schema..min(5),
otherwise: (schema) => schema..min(0),
}),
});

Expand Down
85 changes: 46 additions & 39 deletions src/Condition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,73 +2,80 @@ import isSchema from './util/isSchema';
import Reference from './Reference';
import type { ISchema } from './util/types';

export interface ConditionBuilder<T extends ISchema<any, any>> {
(this: T, value: any, schema: T): ISchema<any, any> | void;
(v1: any, v2: any, schema: T): ISchema<any, any> | void;
(v1: any, v2: any, v3: any, schema: T): ISchema<any, any> | void;
(v1: any, v2: any, v3: any, v4: any, schema: T): ISchema<any, any> | void;
}

export type ConditionConfig<T extends ISchema<any>> = {
export type ConditionBuilder<
T extends ISchema<any, any>,
U extends ISchema<any, any> = T,
> = (values: any[], schema: T, options: ResolveOptions) => U;

export type ConditionConfig<
T extends ISchema<any>,
TThen extends ISchema<any, any> = T,
TOtherwise extends ISchema<any, any> = T,
> = {
is: any | ((...values: any[]) => boolean);
then?: (schema: T) => ISchema<any>;
otherwise?: (schema: T) => ISchema<any>;
then?: (schema: T) => TThen;
otherwise?: (schema: T) => TOtherwise;
};

export type ConditionOptions<T extends ISchema<any, any>> =
| ConditionBuilder<T>
| ConditionConfig<T>;

export type ResolveOptions<TContext = any> = {
value?: any;
parent?: any;
context?: TContext;
};

class Condition<T extends ISchema<any, any> = ISchema<any, any>> {
fn: ConditionBuilder<T>;

constructor(public refs: Reference[], options: ConditionOptions<T>) {
this.refs = refs;

if (typeof options === 'function') {
this.fn = options;
return;
}

if (!('is' in options))
throw new TypeError('`is:` is required for `when()` conditions');

if (!options.then && !options.otherwise)
class Condition<
TIn extends ISchema<any, any> = ISchema<any, any>,
TOut extends ISchema<any, any> = TIn,
> {
fn: ConditionBuilder<TIn, TOut>;

static fromOptions<
TIn extends ISchema<any, any>,
TThen extends ISchema<any, any>,
TOtherwise extends ISchema<any, any>,
>(refs: Reference[], config: ConditionConfig<TIn, TThen, TOtherwise>) {
if (!config.then && !config.otherwise)
throw new TypeError(
'either `then:` or `otherwise:` is required for `when()` conditions',
);

let { is, then, otherwise } = options;
let { is, then, otherwise } = config;

let check =
typeof is === 'function'
? is
: (...values: any[]) => values.every((value) => value === is);

this.fn = function (...args: any[]) {
let _opts = args.pop();
let schema = args.pop();
let branch = check(...args) ? then : otherwise;
return new Condition<TIn, TThen | TOtherwise>(
refs,
(values, schema: any) => {
let branch = check(...values) ? then : otherwise;

return branch?.(schema) ?? schema;
};
return branch?.(schema) ?? schema;
},
);
}

resolve(base: T, options: ResolveOptions) {
constructor(public refs: Reference[], builder: ConditionBuilder<TIn, TOut>) {
this.refs = refs;
this.fn = builder;
}

resolve(base: TIn, options: ResolveOptions) {
let values = this.refs.map((ref) =>
// TODO: ? operator here?
ref.getValue(options?.value, options?.parent, options?.context),
);

let schema = this.fn.apply(base, values.concat(base, options) as any);
let schema = this.fn(values, base, options);

if (schema === undefined || schema === base) return base;
if (
schema === undefined ||
// @ts-ignore this can be base
schema === base
) {
return base;
}

if (!isSchema(schema))
throw new TypeError('conditions must return a schema object');
Expand Down
38 changes: 32 additions & 6 deletions src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
import cloneDeep from 'nanoclone';

import { mixed as locale } from './locale';
import Condition, { ConditionOptions, ResolveOptions } from './Condition';
import Condition, {
ConditionBuilder,
ConditionConfig,
ResolveOptions,
} from './Condition';
import runTests from './util/runTests';
import createValidation, {
TestFunction,
Expand Down Expand Up @@ -670,11 +674,29 @@ export default abstract class BaseSchema<
return next;
}

when(options: ConditionOptions<this>): this;
when(keys: string | string[], options: ConditionOptions<this>): this;
when<U extends ISchema<any> = this>(builder: ConditionBuilder<this, U>): U;
when<U extends ISchema<any> = this>(
keys: string | string[],
builder: ConditionBuilder<this, U>,
): U;
when<
UThen extends ISchema<any> = this,
UOtherwise extends ISchema<any> = this,
>(options: ConditionConfig<this, UThen, UOtherwise>): UThen | UOtherwise;
when<
UThen extends ISchema<any> = this,
UOtherwise extends ISchema<any> = this,
>(
keys: string | string[],
options: ConditionConfig<this, UThen, UOtherwise>,
): UThen | UOtherwise;
when(
keys: string | string[] | ConditionOptions<this>,
options?: ConditionOptions<this>,
keys:
| string
| string[]
| ConditionBuilder<this, any>
| ConditionConfig<this, any, any>,
options?: ConditionBuilder<this, any> | ConditionConfig<this, any, any>,
) {
if (!Array.isArray(keys) && typeof keys !== 'string') {
options = keys;
Expand All @@ -689,7 +711,11 @@ export default abstract class BaseSchema<
if (dep.isSibling) next.deps.push(dep.key);
});

next.conditions.push(new Condition(deps, options!) as Condition);
next.conditions.push(
typeof options === 'function'
? new Condition(deps, options!)
: Condition.fromOptions(deps, options!),
);

return next;
}
Expand Down
4 changes: 2 additions & 2 deletions test/array.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,8 @@ describe('Array types', () => {
let value = ['2', '3'];
let expectedPaths = ['[0]', '[1]'];

let itemSchema = string().when([], function (_, context) {
let path = context.path;
let itemSchema = string().when([], function (_, _s, opts: any) {
let path = opts.path;
expect(expectedPaths).toContain(path);
return string().required();
});
Expand Down
4 changes: 2 additions & 2 deletions test/mixed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -764,12 +764,12 @@ describe('Mixed Types ', () => {

it('should handle multiple conditionals', function () {
let called = false;
let inst = mixed().when(['$prop', '$other'], (prop, other) => {
let inst = mixed().when(['$prop', '$other'], ([prop, other], schema) => {
expect(other).toBe(true);
expect(prop).toBe(1);
called = true;

return mixed();
return schema;
});

inst.cast({}, { context: { prop: 1, other: true } });
Expand Down
18 changes: 8 additions & 10 deletions test/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -675,9 +675,7 @@ describe('Object types', () => {
let inst = object().shape({
noteDate: number()
.when('stats.isBig', { is: true, then: (s) => s.min(5) })
.when('other', function (v) {
if (v === 4) return this.max(6);
}),
.when('other', ([v], schema) => (v === 4 ? schema.max(6) : schema)),
stats: object({ isBig: bool() }),
other: number()
.min(1)
Expand Down Expand Up @@ -780,23 +778,23 @@ describe('Object types', () => {
it('should allow opt out of topo sort on specific edges', () => {
expect(() => {
object().shape({
orgID: number().when('location', (v, schema) => {
if (v == null) return schema.required();
orgID: number().when('location', ([v], schema) => {
return v == null ? schema.required() : schema;
}),
location: string().when('orgID', (v, schema) => {
if (v == null) return schema.required();
return v == null ? schema.required() : schema;
}),
});
}).toThrowError('Cyclic dependency, node was:"location"');

expect(() => {
object().shape(
{
orgID: number().when('location', function (v) {
if (v == null) return this.required();
orgID: number().when('location', ([v], schema) => {
return v == null ? schema.required() : schema;
}),
location: string().when('orgID', function (v) {
if (v == null) return this.required();
location: string().when('orgID', ([v], schema) => {
return v == null ? schema.required() : schema;
}),
},
[['location', 'orgID']],
Expand Down
33 changes: 32 additions & 1 deletion test/types/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-unused-expressions */
/* eslint-disable no-unused-labels */
import { array, number, string, date, ref, mixed } from '../../src';
import { array, number, string, date, ref, mixed, bool } from '../../src';
import { create as lazy } from '../../src/Lazy';
import ObjectSchema, { create as object } from '../../src/object';

Expand Down Expand Up @@ -720,3 +720,34 @@ Object: {
deepPartial.validateSync({})!.address!.line1;
}
}

Conditions: {
// $ExpectType StringSchema<string, AnyObject, undefined, ""> | NumberSchema<number | undefined, AnyObject, undefined, "">
string().when('foo', ([foo], schema) => (foo ? schema.required() : number()));

// $ExpectType StringSchema<string | undefined, AnyObject, undefined, "">
string().when('foo', ([foo], schema) => (foo ? schema.required() : schema));

// $ExpectType StringSchema<string, AnyObject, undefined, ""> | NumberSchema<number | undefined, AnyObject, undefined, "">
string().when('foo', {
is: true,
then: () => number(),
otherwise: (s) => s.required(),
});

const result = object({
foo: bool().defined(),
polyField: mixed<string>().when('foo', {
is: true,
then: () => number(),
otherwise: (s) => s.required(),
}),
}).cast({ foo: true, polyField: '1' });

// $ExpectType { polyField?: string | number | undefined; foo: boolean; }
result;

mixed()
.when('foo', ([foo]) => (foo ? string() : number()))
.min(1);
}
4 changes: 2 additions & 2 deletions test/yup.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,13 @@ describe('Yup', function () {
inst = object().shape({
num: number().max(4),
nested: object().shape({
arr: array().when('$bar', function (bar) {
arr: array().when('$bar', function ([bar]) {
return bar !== 3
? array().of(number())
: array().of(
object().shape({
foo: number(),
num: number().when('foo', (foo) => {
num: number().when('foo', ([foo]) => {
if (foo === 5) return num;
}),
}),
Expand Down

0 comments on commit da74254

Please sign in to comment.