Skip to content

Conversation

@som-sm
Copy link
Collaborator

@som-sm som-sm commented Jan 1, 2026

This PR introduces a new ObjectMerge type that merges two object types in a way that aligns with runtime behaviour.

For example, merging {a: string} into {a: number} (ObjectMerge<{a: number}, {a: string}>) is a straightforward replacement, resulting in {a: string}. However, merging {a?: string} into {a: number} (ObjectMerge<{a: number}, {a?: string}>) is not as simple as producing {a?: string}. In this case, the correct result is {a: number | string} (or {a: number | string | undefined} when exactOptionalPropertyTypes is disabled).

ObjectMerge correctly handles such scenarios, and many others, which are explained in detail below.


Why not fix Merge?

I initially intended this PR to fix the behaviour of our existing Merge type, but it looks like the Merge type is only meant to be used at the type level, because its JSDoc example itself doesn't align with runtime behaviour.

@example
```
import type {Merge} from 'type-fest';
type Foo = {
[x: string]: unknown;
[x: number]: unknown;
foo: string;
bar: symbol;
};
type Bar = {
[x: number]: number;
[x: symbol]: unknown;
bar: Date;
baz: boolean;
};
export type FooBar = Merge<Foo, Bar>;
//=> {
// [x: string]: unknown;
// [x: number]: number;
// [x: symbol]: unknown;
// foo: string;
// bar: Date;
// baz: boolean;
// }
```

For example, Merge<{[x: number]: unknown}, {[x: number]: number}> cannot be {[x: number]: number} if it needs to align with runtime behaviour:

import type {Merge} from 'type-fest';

const left: {[x: number]: unknown} = {0: '1'};
const right: {[x: number]: number} = {1: 1};

const merged = {...left, ...right} as Merge<typeof left, typeof right>;
//=> {[x: number]: number}

if (0 in merged) {
  merged[0].toFixed(); // 💥 No compile time error, but fails at runtime.
}

So, Merge solves a different use case where we want properties from the second object to always override properties from the first object without considering runtime implications.


ObjectMerge vs Object spread inference

Simple cases

In simple cases, ObjectMerge behaves exactly like TS's default inference, where overlapping properties are typed according to the second object.

declare const left: {a: string; b?: string};
declare const right: {a: number; c?: number};

const inferred = {...left, ...right};
//=> {a: number; c?: number; b?: string}

declare const objectMerge: ObjectMerge<typeof left, typeof right>;
//=> {a: number; c?: number; b?: string}
All cases

// === Simple cases ===
expectType<{a: number; b: string}>({} as ObjectMerge<{a: number}, {b: string}>);
expectType<{a: string}>({} as ObjectMerge<{a: number}, {a: string}>);
expectType<{a: string; b: boolean}>({} as ObjectMerge<{a: number}, {a: string; b: boolean}>);
expectType<{a: string; b: boolean}>({} as ObjectMerge<{a: number; b: boolean}, {a: string}>);
expectType<{a: string; b: string; c: number}>({} as ObjectMerge<{a: number; b: string}, {a: string; c: number}>);
expectType<{a: string; b: boolean}>({} as ObjectMerge<{}, {a: string; b: boolean}>);
expectType<{a: string; b: boolean}>({} as ObjectMerge<{a: string; b: boolean}, {}>);

Optional properties

If the overlapping property is optional in the second object, the behaviour of ObjectMerge depends on the exactOptionalPropertyTypes compiler setting.

  1. With exactOptionalPropertyTypes enabled, the behaviour is identical to TS's default inference.

    declare const left: {a: string; b?: string; c?: string};
    declare const right: {a?: number; b?: number; c: number};
    
    const inferred = {...left, ...right};
    //=> {a: string | number; b?: string | number; c: number}
    
    declare const objectMerge: ObjectMerge<typeof left, typeof right>;
    //=> {a: string | number; b?: string | number; c: number}
    All cases

    // === Optional properties ===
    // Optional in second
    expectType<{a: number | string; b: number; c: boolean}>(
    {} as ObjectMerge<{a: number; b: number}, {a?: string; c: boolean}>,
    );
    // Optional in first
    expectType<{a: string; b: number; c: boolean}>(
    {} as ObjectMerge<{a?: number; b: number}, {a: string; c: boolean}>,
    );
    // Optional in both
    expectType<{a?: number | string; b: number; c: boolean}>(
    {} as ObjectMerge<{a?: number; b: number}, {a?: string; c: boolean}>,
    );
    // Optionality preserved for non-overlapping keys
    expectType<{a: string; b?: number; c?: string}>(
    {} as ObjectMerge<{a: number; b?: number}, {a: string; c?: string}>,
    );
    // Mix
    expectType<{a?: number | string; b: string | number; c: string; d: boolean; e?: bigint; f: boolean; g?: bigint}>(
    {} as ObjectMerge<
    {a?: number; b: string; c?: number; d: boolean; e?: bigint},
    {a?: string; b?: number; c: string; f: boolean; g?: bigint}
    >,
    );

  2. With exactOptionalPropertyTypes disabled, the default inference doesn't account for the fact that optional properties can also be set as undefined, which can lead to uncaught runtime errors.

    // @exactOptionalPropertyTypes: false
    
    const left: {a: string} = {a: '0'};
    const right: {a?: number} = {a: undefined};
    
    const inferred = {...left, ...right};
    //=> {a: string | number}
    
    inferred.a.toString(); // 💥 No compile time error, but fails at runtime.
    
    declare const objectMerge: ObjectMerge<typeof left, typeof right>;
    //=> {a: string | number | undefined}
    
    // @ts-expect-error
    objectMerge.a.toString(); // ✅ Correctly errors at compile time.

    In this case, ObjectMerge accounts for the possibility of undefined by including it in the resulting type.

Readonly properties

The behaviour of ObjectMerge is exactly like TS's default inference, i.e., the readonly modifier is removed from all properties across both objects.

declare const left: {a: string; readonly b: string; readonly c: string};
declare const right: {readonly a: number; readonly b: number; c: number};

const inferred = {...left, ...right};
//=> {a: number; b: number; c: number}

declare const objectMerge: ObjectMerge<typeof left, typeof right>;
//=> {a: number; b: number; c: number}
All cases

// === Readonly properties ===
// Readonly in second
expectType<{a: string; b: number; c: boolean}>(
{} as ObjectMerge<{a: number; b: number}, {readonly a: string; c: boolean}>,
);
expectType<{[x: string]: number | string; a: string | number}>(
{} as ObjectMerge<{a: string}, {readonly [x: string]: number}>,
);
// Readonly in first
expectType<{a: string; b: number; c: boolean}>(
{} as ObjectMerge<{readonly a: number; b: number}, {a: string; c: boolean}>,
);
expectType<{[x: string]: number | string; b: string}>(
{} as ObjectMerge<{readonly [x: string]: number}, {b: string}>,
);
// Readonly in both
expectType<{a: string; b: number; c: boolean}>(
{} as ObjectMerge<{readonly a: number; b: number}, {readonly a: string; c: boolean}>,
);
expectType<{[x: string]: number | string; b: number | string; c: string}>(
{} as ObjectMerge<{readonly [x: string]: number; b: number}, {readonly [x: string]: string; c: string}>,
);
// Readonly is not preserved even for non-overlapping keys
expectType<{a: string; b: number; c: string}>(
{} as ObjectMerge<{a: number; readonly b: number}, {a: string; readonly c: string}>,
);
expectType<{[x: string]: string; [x: symbol]: number}>(
{} as ObjectMerge<{readonly [x: string]: string}, {readonly [x: symbol]: number}>,
);
// Mix
expectType<{a: string; b: number; c: string; d: boolean; e: bigint; f: boolean; g: bigint}>(
{} as ObjectMerge<
{readonly a: number; b: string; readonly c: number; d: boolean; readonly e: bigint},
{readonly a: string; readonly b: number; c: string; f: boolean; readonly g: bigint}
>,
);

Number-like string keys

ObjectMerge recognises that number 0 and string '0' are the same thing, just like TS's default inference.

declare const left: {0: string; '1': string};
declare const right: {'0': number; 1: number};

const inferred = {...left, ...right};
//=> {'0': number; 1: number}

declare const objectMerge: ObjectMerge<typeof left, typeof right>;
//=> {'0': number; 1: number}
All cases

// === Normalized keys cases ===
// ===== Cases covering branch that handles literal keys of `Second` =====
// String/number key overwrites corresponding number/string key
expectType<{0: string; 1: string}>({} as ObjectMerge<{'0': number}, {0: string; 1: string}>);
expectType<{'0': string; '1': string}>({} as ObjectMerge<{0?: number}, {'0': string; '1': string}>);
// String/number key with number/string index signature
expectType<{[x: string]: number | string; 0: string}>({} as ObjectMerge<{[x: string]: number}, {0: string}>);
expectType<{[x: number]: number | string; '0': string}>({} as ObjectMerge<{[x: number]: number}, {'0': string}>);
// Optional number key with string index signature
expectType<{[x: string]: number | string; 0?: string | number}>({} as ObjectMerge<{[x: string]: number}, {0?: string}>);
// Optional string key with number index signature
expectType<{[x: number]: number | string; [x: symbol]: boolean; '0'?: string | number}>(
// The `symbol` index signature is added because
// `{[x: number]: number}[never]` yields `number` instead of `never`,
// but if we add a `symbol` index signature to it, then
// `{[x: number]: number; [x: symbol]: boolean}[never]` yields `never` as expected.
{} as ObjectMerge<{[x: number]: number; [x: symbol]: boolean}, {'0'?: string}>,
);
// ===== Cases covering branch that handles literal keys of `First` =====
// String/number key from `First` doesn't show up in output if corresponding number/string key exists in `Second`
expectType<{0: string; '1': number}>({} as ObjectMerge<{'0': number; '1': number}, {0: string}>);
expectType<{'0': string; 1: number}>({} as ObjectMerge<{0: number; 1: number}, {'0': string}>);
// Number/string key from `First` with string/number index signature in `Second`
expectType<{[x: string]: string | number; 0: number | string}>({} as ObjectMerge<{0: number}, {[x: string]: string}>);
expectType<{[x: number]: string | number; [x: symbol]: boolean; '0': number | string}>(
{} as ObjectMerge<{'0': number}, {[x: number]: string; [x: symbol]: boolean}>,
);
// ===== Cases covering branch that handles non-literal keys of `Second` =====
// String/number index signature in `Second` with number/string key in `First`
expectType<{[x: string]: number | string; 0: string | number}>({} as ObjectMerge<{0: string}, {[x: string]: number}>);
expectType<{[x: number]: number | string; '0': string | number}>({} as ObjectMerge<{'0': string}, {[x: number]: number}>);
// Index signature in `Second` with overwritten key in `First`
expectType<{[x: string]: number; [x: symbol]: boolean; 0: 1 | 2 | 3}>(
{} as ObjectMerge<{[x: symbol]: boolean; '0': string}, {[x: string]: number; 0: 1 | 2 | 3}>,
);
// Index signature in `Second` with non-overwritten key in `First`
expectType<{[x: string]: number | string; 0: string | 1 | 2 | 3}>({} as ObjectMerge<{0: string}, {[x: string]: number; '0'?: 1 | 2 | 3}>);
// ===== Cases covering branch that handles non-literal keys of `First` =====
// String/number index signature in `First` with number/string key in `Second`
expectType<{[x: string]: number | string; 0: string}>({} as ObjectMerge<{[x: string]: number}, {0: string}>);
expectType<{[x: number]: number | string; '0': string}>({} as ObjectMerge<{[x: number]: number}, {'0': string}>);
// ===== Cases covering branch that handles optional keys of `Second` that are also present in `First` =====
// Number/string optional key in `Second` with corresponding string/number key in `First`
expectType<{'0': number | string}>({} as ObjectMerge<{'0': number}, {0?: string}>);
expectType<{0: number | string}>({} as ObjectMerge<{0: number}, {'0'?: string}>);
// Number/string optional key in `Second` with corresponding string/number optional key in `First`
expectType<{'0'?: number | string}>({} as ObjectMerge<{'0'?: number}, {0?: string}>);
expectType<{0?: number | string}>({} as ObjectMerge<{0?: number}, {'0'?: string}>);

Index Signatures

  1. In simple cases, ObjectMerge behaves exactly like TS's default inference.

    declare const left: {[x: string]: string};
    declare const right: {[x: string]: number};
    
    const inferred = {...left, ...right};
    //=> {[x: string]: string | number}
    
    declare const objectMerge: ObjectMerge<typeof left, typeof right>;
    //=> {[x: string]: string | number}
  2. In more complicated cases, the default inference completely collapses and this is where ObjectMerge really shines.

    const left: {a: string} = {a: '1'};
    const right: {[x: string]: number} = {a: 1};
    
    const inferred = {...left, ...right};
    //=> {a: string}
    
    inferred.a.toUpperCase(); // 💥 No compile time error, but fails at runtime.
    
    declare const objectMerge: ObjectMerge<typeof left, typeof right>;
    //=> {[x: string]: string | number; a: string | number}
    
    // @ts-expect-error
    objectMerge.a.toUpperCase(); // ✅ Correctly errors at compile time.

    In this example, ObjectMerge correctly recognises that the string index signature from the second object has the potential to overwrite the a property from the first object, therefore it adjusts the resulting type accordingly.


    const left: {[x: string]: string} = {0: 'foo', 1: 'bar'};
    const right: {[x: number]: number} = {0: 111, 3: 999};
    
    const inferred = {...left, ...right};
    //=> {}
    
    declare const objectMerge: ObjectMerge<typeof left, typeof right>;
    //=> {[x: number]: string | number; [x: string]: string | number}

    In this example, the inferred type is {}, which is not very useful. The type produced by ObjectMerge, however, remains accurate and usable.


    const left: {[x: string]: number} = {a: 1};
    const right: {a?: string} = {};
    
    const inferred = {...left, ...right};
    //=> {a?: string}
    
    inferred.a?.toUpperCase(); // 💥 No compile time error, but fails at runtime.
    
    declare const objectMerge: ObjectMerge<typeof left, typeof right>;
    //=> {[x: string]: string | number; a?: string | number}
    
    // @ts-expect-error
    objectMerge.a?.toUpperCase(); // ✅ Correctly errors at compile time.

    In this example, the default inference ignores the possibility that a could be a number at runtime, whereas ObjectMerge correctly accounts for it.

    There are open issues in TS related to this, refer Object spread drops index signature microsoft/TypeScript#27273, Object spreading produces wrong type with non-literal keys microsoft/TypeScript#56431.

    All cases

    // === Index signatures ===
    // Index signature in second
    expectType<{[x: string]: string | number; a: string | number; b: 1; c: 2}>(
    {} as ObjectMerge<{a: string; b: boolean}, {[x: string]: number; b: 1; c: 2}>,
    );
    expectType<
    {[x: `is${string}`]: boolean | 'y' | 'n'; isLoading: boolean | 'y' | 'n'; isOpen: boolean | 'y' | 'n'; foo: string; bar: number}
    >(
    {} as ObjectMerge<
    {isLoading: 'y' | 'n'; isOpen: 'y' | 'n'; foo: string; bar: number},
    {[x: `is${string}`]: boolean}
    >,
    );
    // Index signature in first
    expectType<{[x: string]: number | string | boolean; a: string; b: 2; c: boolean}>(
    {} as ObjectMerge<{[x: string]: number; a: 1; b: 2}, {a: string; c: boolean}>,
    );
    expectType<
    {[x: `is${string}`]: boolean | 'y' | 'n'; isLoading: 'y' | 'n'; isOpen: 'y' | 'n'; foo: string; bar: number}
    >(
    {} as ObjectMerge<
    {[x: `is${string}`]: boolean},
    {isLoading: 'y' | 'n'; isOpen: 'y' | 'n'; foo: string; bar: number}
    >,
    );
    // Index signature in both
    expectType<{[x: string]: number | string; [sym]: boolean; a: 1 | string; b: 'b'; c: 'c'}>(
    {} as ObjectMerge<{[x: string]: number; [sym]: boolean; a: 1; b: 2}, {[x: string]: string; b: 'b'; c: 'c'}>,
    );
    // Multiple index signatures
    expectType<
    {[x: `on${string}`]: string | string[]; [x: `handle${string}`]: number | number[]; onChange: string | string[]; handleClick: number | number[]}
    >(
    {} as ObjectMerge<
    {onChange: string[]; handleClick: number[]},
    {[x: `on${string}`]: string; [x: `handle${string}`]: number}
    >,
    );
    expectType<
    {[x: `on${string}`]: string | string[]; [x: `handle${string}`]: number | number[]; onChange: string[]; handleClick: number[]}
    >(
    {} as ObjectMerge<
    {[x: `on${string}`]: string; [x: `handle${string}`]: number},
    {onChange: string[]; handleClick: number[]}
    >,
    );
    expectType<
    {[x: string]: string | boolean | bigint; [x: symbol]: number; [x: number]: string | boolean; [x: `is${string}`]: bigint | string}
    >(
    {} as ObjectMerge<
    {[x: string]: string; [x: symbol]: number},
    {[x: number]: boolean; [x: `is${string}`]: bigint}
    >,
    );
    // Indexor in `First` is same as in `Second`
    expectType<{[x: string]: string | number}>(
    {} as ObjectMerge<{[x: string]: string}, {[x: string]: number}>,
    );
    expectType<{[x: `${number}`]: number | string; [x: number]: string | number}>(
    {} as ObjectMerge<{[x: `${number}`]: number}, {[x: number]: string}>,
    );
    // Indexor in `First` is supertype of indexor in `Second`
    expectType<{[x: string]: string | number; [x: Lowercase<string>]: string | number}>(
    {} as ObjectMerge<{[x: string]: string}, {[x: Lowercase<string>]: number}>,
    );
    expectType<{[x: string]: number | string; [x: number]: string | number}>(
    {} as ObjectMerge<{[x: string]: number}, {[x: number]: string}>,
    );
    expectType<{[x: string]: string | boolean; [x: `is${string}`]: boolean | string}>(
    {} as ObjectMerge<{[x: string]: string}, {[x: `is${string}`]: boolean}>,
    );
    // Indexor in `First` is subtype of indexor in `Second`
    expectType<{[x: string]: string | number; [x: Lowercase<string>]: string | number}>(
    {} as ObjectMerge<{[x: Lowercase<string>]: string}, {[x: string]: number}>,
    );
    expectType<{[x: number]: number | string; [x: string]: string | number}>(
    {} as ObjectMerge<{[x: number]: number}, {[x: string]: string}>,
    );
    // No overlap b/w indexors
    expectType<{[x: symbol]: number; [x: number]: string}>(
    {} as ObjectMerge<{[x: symbol]: number}, {[x: number]: string}>,
    );
    // Partial overlap b/w indexors
    expectType<{[x: Lowercase<string>]: string | number; [x: Uppercase<string>]: string | number}>(
    {} as ObjectMerge<{[x: Lowercase<string>]: number}, {[x: Uppercase<string>]: string}>,
    );
    // === Index signatures and optional properties ===
    expectType<{[x: string]: string | number; a?: string | number}>(
    {} as ObjectMerge<{a?: string}, {[x: string]: number}>,
    );
    expectType<{[x: string]: string | number; a?: string | number}>(
    {} as ObjectMerge<{[x: string]: number}, {a?: string}>,
    );
    expectType<{[x: string]: number | string; a: number | string}>(
    {} as ObjectMerge<{a: string}, {[x: string]: number; a?: number}>,
    );
    expectType<{[x: string]: number | string; a: string}>(
    {} as ObjectMerge<{[x: string]: number; a?: number}, {a: string}>,
    );
    expectType<{[x: string]: number | string; a?: number | string}>(
    {} as ObjectMerge<{[x: string]: number; a?: number}, {a?: string}>,
    );

There are many more cases that ObjectMerge handles beyond the ones shown here. There’s a fairly comprehensive test suite, and I’ve tried to make sure that all kinds of scenarios are covered and that ObjectMerge always stays in line with the actual runtime behaviour.

@som-sm som-sm force-pushed the fix/rewrite-merge-type branch from 89daf4d to 1a3b324 Compare January 6, 2026 07:24
@som-sm som-sm changed the title Merge: Fix behaviour with optional properties and index signatures Add ObjectMerge type Jan 6, 2026
Repository owner deleted a comment from claude bot Jan 6, 2026
Repository owner deleted a comment from claude bot Jan 6, 2026
Repository owner deleted a comment from claude bot Jan 7, 2026
Repository owner deleted a comment from claude bot Jan 7, 2026
@som-sm som-sm force-pushed the fix/rewrite-merge-type branch from 521fd43 to 2283d3a Compare January 7, 2026 14:09
Repository owner deleted a comment from claude bot Jan 7, 2026
Repository owner deleted a comment from claude bot Jan 7, 2026
@som-sm som-sm force-pushed the fix/rewrite-merge-type branch from 375cb15 to dc6b43b Compare January 8, 2026 06:48
Repository owner deleted a comment from claude bot Jan 8, 2026
Repository owner deleted a comment from claude bot Jan 8, 2026
@som-sm som-sm marked this pull request as ready for review January 8, 2026 07:21
@som-sm som-sm requested a review from sindresorhus January 8, 2026 07:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants