Skip to content
30 changes: 22 additions & 8 deletions source/merge-deep.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,13 @@ type MergeDeepRecordProperty<
Destination,
Source,
Options extends MergeDeepInternalOptions,
> = undefined extends Source
? MergeDeepOrReturn<Source, Exclude<Destination, undefined>, Exclude<Source, undefined>, Options> | undefined
: MergeDeepOrReturn<Source, Destination, Source, Options>;
Default = Source, // Used for debug only
>
= undefined extends Source
? MergeDeepOrReturn<Default, Exclude<Destination, undefined>, Exclude<Source, undefined>, Options> | undefined
: MergeDeepOrReturn<Default, Exclude<Destination, undefined>, Exclude<Source, undefined>, Options>
// We cannot add `| undefined` here to follow the behavior of merging into "unknown"
;

/**
Walk through the union of the keys of the two objects and test in which object the properties are defined.
Expand All @@ -58,7 +62,12 @@ type DoMergeDeepRecord<
}
// Case in rule 3: Both the source and the destination contain the key.
& {
[Key in keyof Source as Key extends keyof Destination ? Key : never]: MergeDeepRecordProperty<Destination[Key], Source[Key], Options>;
[Key in keyof Source as Key extends keyof Destination ? Key : never]:
MergeDeepRecordProperty<
Destination[Key],
Source[Key],
Options
>
};

/**
Expand Down Expand Up @@ -123,7 +132,7 @@ type TypeNumberOrType<Type extends UnknownArrayOrTuple> = Type[number] extends n
type PickRestTypeFlat<Type extends UnknownArrayOrTuple> = TypeNumberOrType<PickRestType<Type>>;

/**
Try to merge two array/tuple elements or return the source element if the end of the destination is reached or vis-versa.
Try to merge two array/tuple elements or return the source element if the end of the destination is reached or vice versa.
*/
type MergeDeepArrayOrTupleElements<
Destination,
Expand Down Expand Up @@ -296,22 +305,27 @@ type MergeDeepArrayOrTuple<

/**
Try to merge two objects or two arrays/tuples recursively into a new type or return the default value.

@param DefaultType - The default type to return (if the destination type or the source type is undefined).
@param Destination - The destination type.
@param Source - The source type.
@param Options - The {@link MergeDeepInternalOptions}.
*/
type MergeDeepOrReturn<
DefaultType,
Destination,
Source,
Options extends MergeDeepInternalOptions,
> = SimplifyDeepExcludeArray<[undefined] extends [Destination | Source]
? DefaultType
? DefaultType // At least one is undefined
: Destination extends UnknownRecord
? Source extends UnknownRecord
? MergeDeepRecord<Destination, Source, Options>
: DefaultType
: DefaultType // Destination is a Record but Source is not
: Destination extends UnknownArrayOrTuple
? Source extends UnknownArrayOrTuple
? MergeDeepArrayOrTuple<Destination, Source, EnforceOptional<Merge<Options, {spreadTopLevelArrays: false}>>>
: DefaultType
: DefaultType // Destination is an Array or Tuple but Source is not
: DefaultType>;

/**
Expand Down
124 changes: 124 additions & 0 deletions test-d/merge-deep.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {expectType} from 'tsd';
import {expectTypeOf} from 'expect-type';
import type {MergeDeep, MergeDeepOptions} from '../index.d.ts';

// Test helper.
Expand Down Expand Up @@ -187,6 +188,129 @@ expectType<MergedFooBar>(mergedFooBar);
declare const mergedBarFoo: MergeDeep<FooOptional, BarOptional>;
expectType<MergedFooBar>(mergedBarFoo);

// Nested Optional: Ensuring the merge goes deep
type OptionalNestedTest = {
Left: {
nested?: {
in_left: string;
sub_nested?: {
in_both?: number;
};
};
};
Right: {
nested: {
in_right: string;
sub_nested: {
in_both: number;
};
};
};
};

type OptionalNestedRightIntoLeft = MergeDeep<
OptionalNestedTest['Left'],
OptionalNestedTest['Right']
>;
expectTypeOf<OptionalNestedRightIntoLeft>().toEqualTypeOf<{
nested: { // Optional is overwritten by Right
in_left: string; // Subentries are kept in both directions
in_right: string;
sub_nested: { // Optional is overwritten by Right in subentries
in_both: number;
};
};
}>();

type OptionalNestedLeftIntoRight = MergeDeep<
OptionalNestedTest['Right'],
OptionalNestedTest['Left']
>;
expectTypeOf<OptionalNestedLeftIntoRight>().toEqualTypeOf<{
nested?: { // Optional is added by Left
in_left: string; // Subentries are kept in both directions
in_right: string;
sub_nested?: { // Optional is added by Left in subentries
in_both?: number | undefined;
} | undefined;
} | undefined;
// "| undefined" is added here and the only way to remove it
// would depend on exactOptionalPropertyTypes and a complex
// logic which may not worth the effort
Comment on lines +237 to +239
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally, | undefined shouldn't be there.

There's a helper called IsExactOptionalPropertyTypesEnabled, can you try using that?

}>();

// Nested Optional: Optional versus undefined entry
type OptionalOrUndefinedNestedTest = {
Left: {
nested?: {
in_left: string;
};
};
Right: {
nested: {
in_right: number;
} | undefined;
};
};

type OptionalOrUndefinedNestedRightIntoLeft = MergeDeep<
OptionalOrUndefinedNestedTest['Left'],
OptionalOrUndefinedNestedTest['Right']
>;
expectTypeOf<OptionalOrUndefinedNestedRightIntoLeft>().toEqualTypeOf<{
nested: { // ? is overwritten by Right
in_left: string;
in_right: number;
} | undefined; // Undefined is kept in both directions
}>();

type OptionalOrUndefinedNestedRightIntoRight = MergeDeep<
OptionalOrUndefinedNestedTest['Right'],
OptionalOrUndefinedNestedTest['Left']
>;
expectTypeOf<OptionalOrUndefinedNestedRightIntoRight>().toEqualTypeOf<{
nested?: { // ? is added by Left
in_left: string;
in_right: number;
} | undefined; // Undefined is kept in both directions
}>();

// Nested Optional: Optional versus undefined entry
type OptionalAndUndefinedNestedTest = {
Left: {
nested?: {
in_left: string;
} | undefined;
};
Right: {
nested: {
in_right: string;
};
};
};

type OptionalAndUndefinedNestedRightIntoLeft = MergeDeep<
OptionalAndUndefinedNestedTest['Left'],
OptionalAndUndefinedNestedTest['Right']
>;
expectTypeOf<OptionalAndUndefinedNestedRightIntoLeft>().toEqualTypeOf<{
nested: {
in_left: string;
in_right: string;
}; // "| undefined" is removed by Right
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Umm... shouldn't | undefined be not removed here, if it's not being removed in the previous test (as shown below)?

type OptionalOrUndefinedNestedRightIntoRight = MergeDeep<
	{nested: {in_right: number} | undefined},
	{nested?: {in_left: string}}
>;

expectTypeOf<OptionalOrUndefinedNestedRightIntoRight>().toEqualTypeOf<{
	nested?: {in_left: string; in_right: number} | undefined;
}>();

}>();

type OptionalAndUndefinedNestedRightIntoRight = MergeDeep<
OptionalAndUndefinedNestedTest['Right'],
OptionalAndUndefinedNestedTest['Left']
>;
expectTypeOf<OptionalAndUndefinedNestedRightIntoRight>().toEqualTypeOf<{
nested?: {
in_left: string;
in_right: string;
} | undefined; // "| undefined" is added by Left
}>();

// Test for readonly
type ReadonlyFoo = {
readonly string: string;
Expand Down