{ ILoveJS }

Deep merge objects

typescript

A TypeScript function that recursively merges two objects with full type safety. Arrays are concatenated, primitives are overwritten by the source, and nested objects are merged recursively.

objectmergedeep

Code

typescript
type Primitive = string | number | boolean | bigint | symbol | undefined | null;

type DeepMergeable = { [key: string]: unknown };

type DeepMerge<T, U> = T extends Primitive
  ? U
  : U extends Primitive
    ? U
    : T extends readonly unknown[]
      ? U extends readonly unknown[]
        ? [...T, ...U]
        : U
      : U extends readonly unknown[]
        ? U
        : T extends DeepMergeable
          ? U extends DeepMergeable
            ? {
                [K in keyof T | keyof U]: K extends keyof U
                  ? K extends keyof T
                    ? DeepMerge<T[K], U[K]>
                    : U[K]
                  : K extends keyof T
                    ? T[K]
                    : never;
              }
            : U
          : U;

function isObject(value: unknown): value is DeepMergeable {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function deepMerge<T, U>(target: T, source: U): DeepMerge<T, U> {
  if (!isObject(target) || !isObject(source)) {
    if (Array.isArray(target) && Array.isArray(source)) {
      return [...target, ...source] as DeepMerge<T, U>;
    }
    return source as DeepMerge<T, U>;
  }

  const result: DeepMergeable = { ...target };

  for (const key of Object.keys(source)) {
    const sourceValue = source[key];
    const targetValue = result[key];

    if (Array.isArray(sourceValue) && Array.isArray(targetValue)) {
      result[key] = [...targetValue, ...sourceValue];
    } else if (isObject(sourceValue) && isObject(targetValue)) {
      result[key] = deepMerge(targetValue, sourceValue);
    } else {
      result[key] = sourceValue;
    }
  }

  return result as DeepMerge<T, U>;
}

// Example usage with full type inference
const defaultConfig = {
  api: {
    baseUrl: 'https://api.example.com',
    timeout: 5000,
    headers: ['Content-Type'],
  },
  features: {
    darkMode: false,
    notifications: true,
  },
  version: 1,
};

const userConfig = {
  api: {
    timeout: 10000,
    headers: ['Authorization'],
    retries: 3,
  },
  features: {
    darkMode: true,
  },
  environment: 'production' as const,
};

const mergedConfig = deepMerge(defaultConfig, userConfig);

// Type is fully inferred:
// {
//   api: { baseUrl: string; timeout: number; headers: string[]; retries: number };
//   features: { darkMode: boolean; notifications: boolean };
//   version: number;
//   environment: "production";
// }

console.log(mergedConfig);
// Output:
// {
//   api: {
//     baseUrl: 'https://api.example.com',
//     timeout: 10000,
//     headers: ['Content-Type', 'Authorization'],
//     retries: 3
//   },
//   features: { darkMode: true, notifications: true },
//   version: 1,
//   environment: 'production'
// }

export { deepMerge, type DeepMerge };

How It Works

This implementation solves the common problem of merging configuration objects or state updates while maintaining complete type safety. The key insight is that TypeScript's type system can recursively compute the resulting type of a deep merge operation, giving you accurate IntelliSense and compile-time error checking.

The DeepMerge type uses conditional types to handle four distinct cases: primitives (where source wins), arrays (which get concatenated as tuple types), objects (which recurse into nested properties), and mixed scenarios (where source type takes precedence). The mapped type at the object level iterates over the union of both objects' keys, applying DeepMerge recursively for overlapping properties while preserving unique properties from each side.

The runtime implementation mirrors the type logic with a recursive approach. The isObject type guard distinguishes plain objects from arrays and null values, which is critical since JavaScript's typeof null === 'object'. For each property, we check if both values are arrays (concatenate), both are objects (recurse), or otherwise let the source value overwrite. Using object spread for the initial copy ensures we don't mutate the original target.

One important edge case to consider is circular references — this implementation will cause a stack overflow if objects reference themselves. For most configuration merging scenarios this isn't a concern, but if you need to handle circular structures, you'd need to track visited objects with a WeakSet. Similarly, this implementation doesn't handle special objects like Date, Map, Set, or class instances — they're treated as primitives and overwritten entirely.

Use this pattern when merging configuration defaults with user overrides, combining partial state updates in Redux-style reducers, or building plugin systems where multiple sources contribute settings. Avoid it when you need shallow merging for performance (use object spread), when dealing with circular references, or when you need custom merge strategies for specific properties (consider a library like lodash.mergeWith instead).