Skip to content

Commit

Permalink
feat: concat() is shallow and does not merge (#1541)
Browse files Browse the repository at this point in the history
BREAKING CHANGE: concat works shallowly now. Previously concat functioned like a deep merge for object, which produced confusing behavior with incompatible concat'ed schema. Now concat for objects works similar to how it works for other types, the provided schema is applied on top of the existing schema, producing a new schema that is the same as calling each builder method in order
  • Loading branch information
jquense authored Dec 28, 2021
1 parent da74254 commit a2f99d9
Show file tree
Hide file tree
Showing 6 changed files with 82 additions and 62 deletions.
51 changes: 32 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Yup

Yup is a JavaScript schema builder for value parsing and validation. Define a schema, transform a value to match, validate the shape of an existing value, or both. Yup schema are extremely expressive and allow modeling complex, interdependent validations, or value transformations.
Yup is a JavaScript schema builder for value parsing and validation. Define a schema, transform a value to match, assert the shape of an existing value, or both. Yup schema are extremely expressive and allow modeling complex, interdependent validations, or value transformations.

Yup's API is heavily inspired by [Joi](https://github.com/hapijs/joi), but leaner and built with client-side validation as its primary use-case. Yup separates the parsing and validating functions into separate steps. `cast()` transforms data while `validate` checks that the input is the correct shape. Each can be performed together (such as HTML form validation) or seperately (such as deserializing trusted data from APIs).

Expand Down Expand Up @@ -38,27 +38,22 @@ let schema = yup.object().shape({
age: yup.number().required().positive().integer(),
email: yup.string().email(),
website: yup.string().url(),
createdOn: yup.date().default(function () {
return new Date();
}),
createdOn: yup.date().default(() => new Date()),
});

// check validity
schema
.isValid({
name: 'jimmy',
age: 24,
})
.then(function (valid) {
valid; // => true
});
// parse and assert validity
const parsedValue = await schema.validate({
name: 'jimmy',
age: 24,
});

// you can try and type cast objects to the defined schema
schema.cast({
name: 'jimmy',
age: '24',
createdOn: '2014-09-23T19:25:25Z',
});

// => { name: 'jimmy', age: 24, createdOn: Date }
```

Expand All @@ -77,8 +72,6 @@ import {
} from 'yup';
```

> If you're looking for an easily serializable DSL for yup schema, check out [yup-ast](https://github.com/WASD-Team/yup-ast)
### Using a custom locale dictionary

Allows you to customize the default messages used by Yup, when no message is provided with a validation test.
Expand All @@ -102,10 +95,12 @@ let schema = yup.object().shape({
age: yup.number().min(18),
});

schema.validate({ name: 'jimmy', age: 11 }).catch(function (err) {
try {
await schema.validate({ name: 'jimmy', age: 11 });
} catch (err) {
err.name; // => 'ValidationError'
err.errors; // => ['Deve ser maior que 18']
});
}
```

If you need multi-language support, Yup has got you covered. The function `setLocale` accepts functions that can be used to generate error objects with translation keys and values. Just get this output and feed it into your favorite i18n library.
Expand All @@ -131,10 +126,12 @@ let schema = yup.object().shape({
age: yup.number().min(18),
});

schema.validate({ name: 'jimmy', age: 11 }).catch(function (err) {
try {
await schema.validate({ name: 'jimmy', age: 11 });
} catch (err) {
err.name; // => 'ValidationError'
err.errors; // => [{ key: 'field_too_short', values: { min: 18 } }]
});
}
```

## API
Expand Down Expand Up @@ -385,6 +382,16 @@ SchemaDescription {
#### `mixed.concat(schema: Schema): Schema`

Creates a new instance of the schema by combining two schemas. Only schemas of the same type can be concatenated.
`concat` is not a "merge" function in the sense that all settings from the provided schema, override ones in the
base, including type, presence and nullability.

```ts
mixed<string>().defined().concat(mixed<number>().nullable());

// produces the equivalent to:

mixed<number>().defined().nullable();
```

#### `mixed.validate(value: any, options?: object): Promise<any, ValidationError>`

Expand Down Expand Up @@ -1159,6 +1166,12 @@ object({
});
```

#### `object.concat(schemaB: ObjectSchema): ObjectSchema`

Creates a object schema, by applying all settings and fields from `schemaB` to the base, producing a new schema.
The object shape is shallowly merged with common fields from `schemaB` taking precedence over the base
fields.

#### `object.pick(keys: string[]): Schema`

Create a new schema from a subset of the original's fields.
Expand Down
27 changes: 6 additions & 21 deletions docs/typescript.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,8 @@
## TypeScript Support

`yup` comes with robust typescript support! However, because of how dynamic `yup` is
not everything can be statically typed safely, but for most cases it's "Good Enough".

Note that `yup` schema actually produce _two_ different types: the result of casting an input, and the value after validation.
Why are these types different? Because a schema can produce a value via casting that
would not pass validation!

```js
const schema = string().nullable().required();

schema.cast(null); // -> null
schema.validateSync(null); // ValidationError this is required!
```

By itself this seems weird, but has it's uses when handling user input. To get a
TypeScript type that matches all possible `cast()` values, use `yup.TypeOf<typeof schema>`.
To produce a type that matches a valid object for the schema use `yup.Asserts<typeof schema>>`
`yup` comes with robust typescript support, producing values and types from schema that
provide compile time type safety as well as runtime parsing and validation. Schema refine
their output as

```ts
import * as yup from 'yup';
Expand Down Expand Up @@ -46,12 +32,11 @@ const personSchema = yup.object({
You can derive a type for the final validated object as follows:

```ts
import type { Asserts } from 'yup';
import type { InferType } from 'yup';

// you can also use a type alias by this displays better in tooling
interface Person extends Asserts<typeof personSchema> {}
type Person = InferType<typeof personSchema>;

const validated: Person = personSchema.validateSync(parsed);
const validated: Person = await personSchema.validate(value);
```

If you want the type produced by casting:
Expand Down
12 changes: 4 additions & 8 deletions src/mixed.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AnyObject, Maybe, Message, Optionals } from './types';
import { AnyObject, Maybe, Message } from './types';
import type {
Concat,
Defined,
Flags,
SetFlag,
Expand All @@ -21,15 +22,10 @@ export declare class MixedSchema<

concat<IT, IC, ID, IF extends Flags>(
schema: MixedSchema<IT, IC, ID, IF>,
): MixedSchema<NonNullable<TType> | IT, TContext & IC, ID, TFlags | IF>;
): MixedSchema<Concat<TType, IT>, TContext & IC, ID, TFlags | IF>;
concat<IT, IC, ID, IF extends Flags>(
schema: BaseSchema<IT, IC, ID, IF>,
): MixedSchema<
NonNullable<TType> | Optionals<IT>,
TContext & IC,
ID,
TFlags | IF
>;
): MixedSchema<Concat<TType, IT>, TContext & IC, ID, TFlags | IF>;
concat(schema: this): this;

defined(
Expand Down
21 changes: 7 additions & 14 deletions src/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
ToggleDefault,
UnsetFlag,
} from './util/types';

import { object as locale } from './locale';
import sortFields from './util/sortFields';
import sortByKeyOrder from './util/sortByKeyOrder';
Expand All @@ -22,6 +21,7 @@ import BaseSchema, { SchemaObjectDescription, SchemaSpec } from './schema';
import { ResolveOptions } from './Condition';
import type {
AnyObject,
ConcatObjectTypes,
DefaultFromShape,
MakePartial,
MergeObjectTypes,
Expand Down Expand Up @@ -73,7 +73,7 @@ export default interface ObjectSchema<
// important that this is `any` so that using `ObjectSchema<MyType>`'s default
// will match object schema regardless of defaults
TDefault = any,
TFlags extends Flags = 'd',
TFlags extends Flags = '',
> extends BaseSchema<MakeKeysOptional<TIn>, TContext, TDefault, TFlags> {
default<D extends Maybe<AnyObject>>(
def: Thunk<D>,
Expand Down Expand Up @@ -104,14 +104,14 @@ export default class ObjectSchema<
TIn extends Maybe<AnyObject>,
TContext = AnyObject,
TDefault = any,
TFlags extends Flags = 'd',
TFlags extends Flags = '',
> extends BaseSchema<MakeKeysOptional<TIn>, TContext, TDefault, TFlags> {
fields: Shape<NonNullable<TIn>, TContext> = Object.create(null);

declare spec: ObjectSchemaSpec;

private _sortErrors = defaultSort;
private _nodes: string[] = []; //readonly (keyof TIn & string)[]
private _nodes: string[] = [];

private _excludedEdges: readonly [nodeA: string, nodeB: string][] = [];

Expand Down Expand Up @@ -312,9 +312,9 @@ export default class ObjectSchema<
concat<IIn, IC, ID, IF extends Flags>(
schema: ObjectSchema<IIn, IC, ID, IF>,
): ObjectSchema<
NonNullable<TIn> | IIn,
ConcatObjectTypes<TIn, IIn>,
TContext & IC,
TDefault & ID,
Extract<IF, 'd'> extends never ? _<ConcatObjectTypes<TDefault, ID>> : ID,
TFlags | IF
>;
concat(schema: this): this;
Expand All @@ -324,14 +324,7 @@ export default class ObjectSchema<
let nextFields = next.fields;
for (let [field, schemaOrRef] of Object.entries(this.fields)) {
const target = nextFields[field];
if (target === undefined) {
nextFields[field] = schemaOrRef;
} else if (
target instanceof BaseSchema &&
schemaOrRef instanceof BaseSchema
) {
nextFields[field] = schemaOrRef.concat(target);
}
nextFields[field] = target === undefined ? schemaOrRef : target;
}

return next.withMutation((s: any) =>
Expand Down
9 changes: 9 additions & 0 deletions src/util/objectTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ export type MergeObjectTypes<T extends Maybe<AnyObject>, U extends AnyObject> =
| ({ [P in keyof T]: P extends keyof U ? U[P] : T[P] } & U)
| Optionals<T>;

export type ConcatObjectTypes<
T extends Maybe<AnyObject>,
U extends Maybe<AnyObject>,
> =
| ({
[P in keyof T]: P extends keyof NonNullable<U> ? NonNullable<U>[P] : T[P];
} & U)
| Optionals<U>;

export type PartialDeep<T> = T extends
| string
| number
Expand Down
24 changes: 24 additions & 0 deletions test/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,30 @@ Object: {
// $ExpectType string
merge.cast({}).other;

Concat: {
const obj1 = object({
field: string().required(),
other: string().default(''),
});

const obj2 = object({
field: number().default(1),
name: string(),
}).nullable();

// $ExpectType { name?: string | undefined; other: string; field: number; } | null
obj1.concat(obj2).cast('');

// $ExpectType { name?: string | undefined; other: string; field: number; }
obj1.nullable().concat(obj2.nonNullable()).cast('');

// $ExpectType { field: 1; other: ""; name: undefined; }
obj1.nullable().concat(obj2.nonNullable()).getDefault();

// $ExpectType null
obj1.concat(obj2.default(null)).getDefault();
}

SchemaOfDate: {
type Employee = {
hire_date: Date;
Expand Down

0 comments on commit a2f99d9

Please sign in to comment.