Skip to content

Commit 1a3b324

Browse files
committed
feat: add ObjectMerge type
1 parent fe88ad8 commit 1a3b324

File tree

3 files changed

+262
-0
lines changed

3 files changed

+262
-0
lines changed

index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export type {TaggedUnion} from './source/tagged-union.d.ts';
2222
export type {Writable} from './source/writable.d.ts';
2323
export type {WritableDeep} from './source/writable-deep.d.ts';
2424
export type {Merge} from './source/merge.d.ts';
25+
export type {ObjectMerge} from './source/object-merge.d.ts';
2526
export type {MergeDeep, MergeDeepOptions} from './source/merge-deep.d.ts';
2627
export type {MergeExclusive} from './source/merge-exclusive.d.ts';
2728
export type {RequireAtLeastOne} from './source/require-at-least-one.d.ts';

source/object-merge.d.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import type {If} from './if.d.ts';
2+
import type {StringToNumber, ToString} from './internal/string.d.ts';
3+
import type {IsExactOptionalPropertyTypesEnabled} from './internal/type.d.ts';
4+
import type {IsNever} from './is-never.d.ts';
5+
import type {IsOptionalKeyOf} from './is-optional-key-of.d.ts';
6+
import type {OmitIndexSignature} from './omit-index-signature.d.ts';
7+
import type {PickIndexSignature} from './pick-index-signature.d.ts';
8+
import type {RequiredKeysOf} from './required-keys-of.d.ts';
9+
import type {Simplify} from './simplify.d.ts';
10+
11+
/**
12+
@category Object
13+
*/
14+
export type ObjectMerge<First, Second> = First extends unknown // For distributing `First`
15+
? Second extends unknown // For distributing `Second`
16+
? _ObjectMerge<
17+
First & object,
18+
Second & object,
19+
NormalizedLiteralKeys<First>,
20+
NormalizedLiteralKeys<Second>,
21+
IsExactOptionalPropertyTypesEnabled extends true ? Required<First> : First,
22+
IsExactOptionalPropertyTypesEnabled extends true ? Required<Second> : Second
23+
>
24+
: never // Should never happen
25+
: never; // Should never happen
26+
27+
type _ObjectMerge<
28+
First extends object,
29+
Second extends object,
30+
NormalizedFirstLiteralKeys,
31+
NormalizedSecondLiteralKeys,
32+
NormalizedFirst extends object,
33+
NormalizedSecond extends object,
34+
> = Simplify<{
35+
// Map over literal keys of `Second`, except those that are optional and also present in `First`.
36+
[P in keyof Second as P extends NormalizedSecondLiteralKeys
37+
? P extends NormalizedFirstLiteralKeys
38+
? If<IsOptionalKeyOf<Second, P>, never, P>
39+
: P
40+
: never]:
41+
| Second[P]
42+
| (P extends NormalizedKeys<keyof PickIndexSignature<First>>
43+
? If<IsOptionalKeyOf<Second, P>, First[NormalizedKeys<P> & keyof First], never>
44+
: never)
45+
} & {
46+
// Map over literal keys of `First`, except those that are not present in `Second`.
47+
[P in keyof First as P extends NormalizedFirstLiteralKeys
48+
? P extends NormalizedSecondLiteralKeys
49+
? never
50+
: P
51+
: never]:
52+
| First[P]
53+
// If there's a matching index signature in `Second`, then add the type for it as well,
54+
// for example, in `Merge<{a: string}, {[x: string]: number}>`, `a` is of type `string | number`.
55+
| (P extends NormalizedKeys<keyof Second>
56+
? Second[NormalizedKeys<P> & keyof Second]
57+
: never);
58+
} & {
59+
// Map over non-literal keys of `Second`.
60+
[P in keyof Second as P extends NormalizedSecondLiteralKeys ? never : P]:
61+
| Second[P]
62+
// If there's a matching key in `First`, then add the type for it as well,
63+
// for example, in `Merge<{a: number}, {[x: string]: string}>`,
64+
// the resulting type is `{[x: string]: number | string; a: number | string}`.
65+
// But, exclude keys from `First` that would surely get overwritten,
66+
// for example, in `Merge<{a: number}, {[x: string]: string; a: string}>`,
67+
// `a` from `First` would get overwritten by `a` from `Second`, so don't add type for it.
68+
| (NormalizedKeys<P> & Exclude<keyof First, NormalizedKeys<RequiredKeysOf<OmitIndexSignature<Second>>>> extends infer NonOverwrittenKeysOfFirst
69+
? If<IsNever<NonOverwrittenKeysOfFirst>, // This check is required because indexing with `never` doesn't always yield `never`, for example, `{[x: string]: number}[never]` results in `number`.
70+
never,
71+
NormalizedFirst[NonOverwrittenKeysOfFirst & keyof NormalizedFirst]>
72+
: never); // Should never happen
73+
} & {
74+
// Map over non-literal keys of `First`
75+
[P in keyof First as P extends NormalizedFirstLiteralKeys ? never : P]:
76+
| First[P]
77+
| If<IsNever<NormalizedKeys<P> & keyof Second>, // This check is required because indexing with `never` doesn't always yield `never`, for example, `{[x: string]: number}[never]` results in `number`.
78+
never,
79+
NormalizedSecond[NormalizedKeys<P> & keyof NormalizedSecond]>;
80+
} & {
81+
// Handle optional keys of `Second` that are also present in `First`.
82+
// Map over `First` instead of `Second` because the modifier is in accordance with `First`.
83+
[P in keyof First as P extends NormalizedFirstLiteralKeys
84+
? P extends NormalizedSecondLiteralKeys
85+
? If<IsOptionalKeyOf<Second, NormalizedKeys<P> & keyof Second>, P, never>
86+
: never
87+
: never]:
88+
| First[P]
89+
| NormalizedSecond[NormalizedKeys<P> & keyof NormalizedSecond]
90+
}>;
91+
92+
type NormalizedKeys<Keys extends PropertyKey> =
93+
| Keys
94+
| (string extends Keys ? number : never)
95+
| StringToNumber<Keys & string>
96+
| ToString<Keys & number>;
97+
98+
type NormalizedLiteralKeys<Type> = Type extends unknown // For distributing `Type`
99+
? NormalizedKeys<keyof OmitIndexSignature<Type>>
100+
: never; // Should never happen
101+
102+
export {};

test-d/object-merge.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import {expectType} from 'tsd';
2+
import type {ObjectMerge} from '../source/object-merge.d.ts';
3+
4+
// Simple cases
5+
expectType<{a: number; b: string}>({} as ObjectMerge<{a: number}, {b: string}>);
6+
expectType<{a: string}>({} as ObjectMerge<{a: number}, {a: string}>);
7+
expectType<{a: string; b: boolean}>({} as ObjectMerge<{a: number}, {a: string; b: boolean}>);
8+
expectType<{a: string; b: string; c: number}>({} as ObjectMerge<{a: number; b: string}, {a: string; c: number}>);
9+
expectType<{a: string; b: boolean}>({} as ObjectMerge<{}, {a: string; b: boolean}>);
10+
expectType<{a: string; b: boolean}>({} as ObjectMerge<{a: string; b: boolean}, {}>);
11+
12+
// Optional properties
13+
// Optional only in second
14+
expectType<{a: number | string; b: number; c: boolean}>(
15+
{} as ObjectMerge<{a: number; b: number}, {a?: string; c: boolean}>,
16+
);
17+
// Optional only in first
18+
expectType<{a: string; b: number; c: boolean}>(
19+
{} as ObjectMerge<{a?: number; b: number}, {a: string; c: boolean}>,
20+
);
21+
// Optional in both
22+
expectType<{a?: number | string; b: number; c: boolean}>(
23+
{} as ObjectMerge<{a?: number; b: number}, {a?: string; c: boolean}>,
24+
);
25+
// Optionality preserved for non-overlapping keys
26+
expectType<{a: string; b?: number; c?: string}>(
27+
{} as ObjectMerge<{a: number; b?: number}, {a: string; c?: string}>,
28+
);
29+
// Mix
30+
expectType<{a?: number | string; b: string | number; c: string; d: boolean; e?: bigint; f: boolean; g?: bigint}>(
31+
{} as ObjectMerge<
32+
{a?: number; b: string; c?: number; d: boolean; e?: bigint},
33+
{a?: string; b?: number; c: string; f: boolean; g?: bigint}
34+
>,
35+
);
36+
37+
// TODO: Readonly properties
38+
39+
// Index signatures
40+
expectType<{[x: string]: string | number | boolean; a: string | number; b: boolean | number}>( // TODO: Make tests like this, double properties
41+
{} as ObjectMerge<{a: string; b: boolean}, {[x: string]: number}>,
42+
);
43+
expectType<{[x: `handle${string}`]: number; [x: `on${string}`]: string | number; onChange: string | number}>(
44+
{} as ObjectMerge<{onChange: string}, {[x: `on${string}` | `handle${string}`]: number}>,
45+
);
46+
expectType<{[x: string]: string | number; a: string}>(
47+
{} as ObjectMerge<{[x: string]: number}, {a: string}>,
48+
);
49+
expectType<{[x: string]: number; a: 1 | 2 | 3}>(
50+
{} as ObjectMerge<{a: string}, {[x: string]: number; a: 1 | 2 | 3}>,
51+
);
52+
expectType<{[x: string]: string | number; a: string}>(
53+
{} as ObjectMerge<{[x: string]: number; a: 1 | 2 | 3}, {a: string}>,
54+
);
55+
expectType<{[x: number]: string; a: number; b?: number}>(
56+
{} as ObjectMerge<{a: number; b?: number}, {[x: number]: string}>,
57+
);
58+
expectType<{[x: number]: string; a: number; b?: number}>(
59+
{} as ObjectMerge<{[x: number]: string}, {a: number; b?: number}>,
60+
);
61+
62+
// Indexor in `First` is same as in `Second`
63+
expectType<{[x: string]: string | number}>(
64+
{} as ObjectMerge<{[x: string]: string}, {[x: string]: number}>,
65+
);
66+
// Indexor in `First` is supertype of indexor in `Second`
67+
expectType<{[x: string]: string | number; [x: Lowercase<string>]: string | number}>(
68+
{} as ObjectMerge<{[x: string]: string}, {[x: Lowercase<string>]: number}>,
69+
);
70+
// Indexor in `First` is subtype of indexor in `Second`
71+
expectType<{[x: string]: string | number; [x: Lowercase<string>]: string | number}>(
72+
{} as ObjectMerge<{[x: Lowercase<string>]: string}, {[x: string]: number}>,
73+
);
74+
// No overlap b/w indexors
75+
expectType<{[x: symbol]: number; [x: number]: string}>(
76+
{} as ObjectMerge<{[x: symbol]: number}, {[x: number]: string}>,
77+
);
78+
// Partial overlap b/w indexors
79+
expectType<{[x: Lowercase<string>]: string | number; [x: Uppercase<string>]: string | number}>(
80+
{} as ObjectMerge<{[x: Lowercase<string>]: number}, {[x: Uppercase<string>]: string}>,
81+
);
82+
83+
// Index signatures and optional properties
84+
expectType<{[x: string]: string | number; a?: string | number}>(
85+
{} as ObjectMerge<{a?: string}, {[x: string]: number}>,
86+
);
87+
expectType<{[x: string]: string | number; a?: string | number}>(
88+
{} as ObjectMerge<{[x: string]: number}, {a?: string}>,
89+
);
90+
expectType<{[x: string]: number | string; a: number | string}>(
91+
{} as ObjectMerge<{a: string}, {[x: string]: number; a?: number}>,
92+
);
93+
expectType<{[x: string]: number | string; a: string}>(
94+
{} as ObjectMerge<{[x: string]: number; a?: number}, {a: string}>,
95+
);
96+
97+
// === `number` indexors ===
98+
// ===== Cases covering branch that handles literal keys of `Second` =====
99+
// String/number key overwrites corresponding number/string key
100+
expectType<{0: string; 1: string}>({} as ObjectMerge<{'0': number}, {0: string; 1: string}>);
101+
expectType<{'0': string; '1': string}>({} as ObjectMerge<{0?: number}, {'0': string; '1': string}>);
102+
// String/number key with number/string index signature
103+
expectType<{[x: string]: number | string; 0: string}>({} as ObjectMerge<{[x: string]: number}, {0: string}>);
104+
expectType<{[x: number]: number | string; '0': string}>({} as ObjectMerge<{[x: number]: number}, {'0': string}>);
105+
// Optional number key with string index signature
106+
expectType<{[x: string]: number | string; 0?: string | number}>({} as ObjectMerge<{[x: string]: number}, {0?: string}>);
107+
// Optional string key with number index signature
108+
expectType<{[x: number]: number | string; [x: symbol]: boolean; '0'?: string | number}>(
109+
// The `symbol` index signature is added because
110+
// `{[x: number]: number}[never]` yields `number` instead of `never`
111+
// but if add a `symbol` index signature to it, then
112+
// `{[x: number]: number; [x: symbol]: boolean}[never]` yields `never` as expected
113+
{} as ObjectMerge<{[x: number]: number; [x: symbol]: boolean}, {'0'?: string}>,
114+
);
115+
116+
// ===== Cases covering branch that handles literal keys of `First` =====
117+
// String/number key from `First` doesn't show up in output if corresponding number/string key exists in `Second`
118+
expectType<{0: string; '1': number}>({} as ObjectMerge<{'0': number; '1': number}, {0: string}>);
119+
expectType<{'0': string; 1: number}>({} as ObjectMerge<{0: number; 1: number}, {'0': string}>);
120+
// Number/string key from `First` with string/number index signature in `Second`
121+
expectType<{[x: string]: string | number; 0: number | string}>({} as ObjectMerge<{0: number}, {[x: string]: string}>);
122+
expectType<{[x: number]: string | number; [x: symbol]: boolean; '0': number | string}>(
123+
{} as ObjectMerge<{'0': number}, {[x: number]: string; [x: symbol]: boolean}>,
124+
);
125+
126+
// ===== Cases covering branch that handles non-literal keys of `Second` =====
127+
// String/number index signature in `Second` with number/string key in `First`
128+
expectType<{[x: string]: number | string; 0: string | number}>({} as ObjectMerge<{0: string}, {[x: string]: number}>);
129+
expectType<{[x: number]: number | string; '0': string | number}>({} as ObjectMerge<{'0': string}, {[x: number]: number}>);
130+
// Index signature in `Second` with overwritten key in `First`
131+
expectType<{[x: string]: number; [x: symbol]: boolean; 0: 1 | 2 | 3}>(
132+
{} as ObjectMerge<{[x: symbol]: boolean; '0': string}, {[x: string]: number; 0: 1 | 2 | 3}>,
133+
);
134+
// Index signature in `Second` with non-overwritten key in `First`
135+
expectType<{[x: string]: number | string; 0: string | 1 | 2 | 3}>({} as ObjectMerge<{0: string}, {[x: string]: number; '0'?: 1 | 2 | 3}>);
136+
137+
// ===== Cases covering branch that handles non-literal keys of `First` =====
138+
// String/number index signature in `First` with number/string key in `Second`
139+
expectType<{[x: string]: number | string; 0: string}>({} as ObjectMerge<{[x: string]: number}, {0: string}>);
140+
expectType<{[x: number]: number | string; '0': string}>({} as ObjectMerge<{[x: number]: number}, {'0': string}>);
141+
142+
// ===== Cases covering branch that handles optional keys of `Second` that are also present in `First` =====
143+
// Number/string optional key in `Second` with corresponding string/number key in `First`
144+
expectType<{'0': number | string}>({} as ObjectMerge<{'0': number}, {0?: string}>);
145+
expectType<{0: number | string}>({} as ObjectMerge<{0: number}, {'0'?: string}>);
146+
// Number/string optional key in `Second` with corresponding string/number optional key in `First`
147+
expectType<{'0'?: number | string}>({} as ObjectMerge<{'0'?: number}, {0?: string}>);
148+
expectType<{0?: number | string}>({} as ObjectMerge<{0?: number}, {'0'?: string}>);
149+
150+
// Unions
151+
expectType<{a: number; b: string; c: number} | {a: number; c: number; d: string}>(
152+
{} as ObjectMerge<{a: string; b: string} | {c: string; d: string}, {a: number; c: number}>,
153+
);
154+
expectType<{a: number; b: string} | {a: string; b: number}>(
155+
{} as ObjectMerge<{a: string; b: string}, {a: number} | {b: number}>,
156+
);
157+
expectType<{a: number; b: string} | {a: string; b: string; c: number} | {c: number; d: string} | {a: number; c: string; d: string}>(
158+
{} as ObjectMerge<{a: string; b: string} | {c: string; d: string}, {a: number} | {c: number}>,
159+
);

0 commit comments

Comments
 (0)