Repository: jquense/yup
Pull request #1541 (2021-12-28 19:44:16)

feat: concat() is shallow and does not merge
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


Patch:
diff --git a/README.md b/README.md
index 9f8d6cd..71ff949 100644
--- a/README.md
+++ b/README.md
@@ -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).
 
@@ -38,20 +38,14 @@ 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({
@@ -59,6 +53,7 @@ schema.cast({
   age: '24',
   createdOn: '2014-09-23T19:25:25Z',
 });
+
 // => { name: 'jimmy', age: 24, createdOn: Date }
 ```
 
@@ -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.
@@ -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.
@@ -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
@@ -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>`
 
@@ -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.
diff --git a/docs/typescript.md b/docs/typescript.md
index 4acc24d..ecac75f 100644
--- a/docs/typescript.md
+++ b/docs/typescript.md
@@ -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';
@@ -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:
diff --git a/src/mixed.ts b/src/mixed.ts
index 4b2c317..aac0e49 100644
--- a/src/mixed.ts
+++ b/src/mixed.ts
@@ -1,5 +1,6 @@
-import { AnyObject, Maybe, Message, Optionals } from './types';
+import { AnyObject, Maybe, Message } from './types';
 import type {
+  Concat,
   Defined,
   Flags,
   SetFlag,
@@ -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(
diff --git a/src/object.ts b/src/object.ts
index e7ab7f7..7e6648d 100644
--- a/src/object.ts
+++ b/src/object.ts
@@ -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';
@@ -22,6 +21,7 @@ import BaseSchema, { SchemaObjectDescription, SchemaSpec } from './schema';
 import { ResolveOptions } from './Condition';
 import type {
   AnyObject,
+  ConcatObjectTypes,
   DefaultFromShape,
   MakePartial,
   MergeObjectTypes,
@@ -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>,
@@ -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][] = [];
 
@@ -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;
@@ -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) =>
diff --git a/src/util/objectTypes.ts b/src/util/objectTypes.ts
index b5686a1..ea97625 100644
--- a/src/util/objectTypes.ts
+++ b/src/util/objectTypes.ts
@@ -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


Test patch:
diff --git a/test/types/types.ts b/test/types/types.ts
index 2c3a5fe..375944c 100644
--- a/test/types/types.ts
+++ b/test/types/types.ts
@@ -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;