Why Configuration Merging Can Be Tricky

When I work on large Angular or React projects, we often have a base configuration object that defines defaults, and then we need to overlay environment‑specific values (development, staging, production). TypeScript gives us static typing, but a naive merge can strip type safety or cause runtime errors if we forget a property.

The Problem in Practice

Imagine a `AppConfig` interface that looks like this:

interface AppConfig {
  apiUrl: string;
  timeout: number;
  features: {
    auth: boolean;
    logging: boolean;
  };
}

If we write a simple `Object.assign({}, defaults, envConfig)`, TypeScript will happily accept any shape for `envConfig`. The resulting object may have extra fields that TypeScript no longer tracks, or missing fields that cause runtime crashes. Moreover, partial updates (e.g., only overriding `features.auth`) require us to type‑cast the result, which defeats the purpose of strong typing.

The Solution: A Type‑Safe Deep Partial Merge

I keep a small utility in my toolkit that merges two objects while preserving the original type and allowing partial overrides. The core ideas are:

  • **DeepPartial<T>**: a recursive utility type that turns an interface into a type where every property is optional, and nested objects are also DeepPartial.
  • **deepMerge<T, U>**: a runtime function that walks the override object and builds a new object of type `T & U` (the union of both shapes) while never discarding fields.

Using DeepPartial, callers can write `deepMerge(defaults, partialEnv)` and TypeScript will guarantee that `partialEnv` does not contain fields that are not present in `defaults`. The result is a type‑safe, immutable configuration that can be spread across the whole app.

Implementation Details

Below is the exact snippet I copy‑paste into any project that needs this pattern.

/**
 * Recursively makes all properties of T optional, preserving nested object shapes.
 * This is the type we need for safe partial overrides.
 */
export type DeepPartial = Partial<{
  [K in keyof T]: T[K] extends object ? DeepPartial<T[K]> : T[K];
}>;

/**
 * Merges a base object with an override object of the same shape (or a DeepPartial of it).
 * Returns a new object that combines both, favoring the override values.
 * The resulting type is `T & U` – i.e., all fields from both, with overrides winning.
 */
export function deepMerge<T extends object, U extends object>(
  base: T,
  override: U
): T & U {
  // If either argument is not an object, return the override (runtime safety).
  if (!base || typeof base !== 'object' || !override || typeof override !== 'object') {
    return override as T & U;
  }

  // Start with a shallow clone of the base to avoid mutating it.
  const result = { ... (base as any) } as any;

  // Recursively merge each key present in the override.
  for (const key in override) {
    if (!Object.prototype.hasOwnProperty.call(override, key)) {
      continue;
    }
    const baseValue = (base as any)[key];
    const overrideValue = (override as any)[key];

    // If both values are plain objects, recurse; otherwise replace.
    if (
      baseValue && typeof baseValue === 'object' &&
      overrideValue && typeof overrideValue === 'object'
    ) {
      result[key] = deepMerge(baseValue, overrideValue);
    } else {
      result[key] = overrideValue;
    }
  }

  return result;
}

The function is deliberately simple and works with any depth. It also respects arrays (they are treated as values, not merged element‑wise), which is usually what you want for configuration.

Real‑World Scenario

At my current client, we ship a single SPA that runs in three environments. The base config lives in `src/config/defaults.ts`:

export const defaults: AppConfig = {
  apiUrl: 'https://api.example.com',
  timeout: 5000,
  features: {
    auth: true,
    logging: false,
  },
};

Each environment injects its own file, e.g., `src/config/dev.ts`:

import { defaults } from './defaults';
export const config = deepMerge(defaults, {
  apiUrl: 'https://dev-api.example.com',
  features: {
    logging: true,
  },
});

Because `deepMerge` expects a `DeepPartial`, TypeScript validates that we aren’t adding a random field like `randomFlag`. If we accidentally misspell `apiUrl`, the editor highlights the error instantly. The final `config` object is typed as `AppConfig & { apiUrl: string; ... }`, guaranteeing that every part of the application can safely read `config.apiUrl` without a type‑cast.

Tip: Keep the `deepMerge` utility in a dedicated `src/utils/object.utils.ts` file. Export it alongside other reusable helpers so that the whole team can rely on the same type‑safe pattern.

Why This Works: The “Why” Behind the Approach

The beauty of this pattern lies in the combination of two concepts:

  1. **Recursive generic types** – `DeepPartial` mirrors the shape of the original object, turning every leaf into an optional property. This means the override can be as sparse as you like while still being assignable to the full type.
  2. **Runtime recursion with immutability** – `deepMerge` walks the object graph once, never mutating the inputs. The result is a brand‑new object that still carries the exact TypeScript signature we need.

Because the type system guarantees that the override respects the base shape, we eliminate a whole class of bugs: missing fields, extra fields, and accidental type‑casts. The runtime implementation is straightforward enough that it’s easy to audit and test, yet flexible enough to handle nested structures without extra plumbing.

Best Practices & Common Pitfalls

  • Never use `deepMerge` for arrays that need element‑wise merging; treat them as opaque values.
  • If you need to support `Date` or other primitives, add a small discriminator or keep the merge as‑is (primitives are replaced, which is usually fine for config).
  • Consider adding a type guard like `isObject` to avoid false positives with `null` or `undefined`.
  • Write a simple unit test that merges two nested objects and asserts the result type using TypeScript’s `typeof` operator.

Conclusion

A configuration system that feels both ergonomic and safe is a small but mighty win for any TypeScript project. By coupling a recursive `DeepPartial` type with a lightweight `deepMerge` function, you get the best of both worlds: the editor catches mistakes early, and the runtime merge is predictable and immutable. I’ve adopted this pattern across several micro‑frontends and it has reduced configuration‑related bugs by a noticeable margin. If you’re tired of juggling type‑casts or losing type information when you override settings, give this snippet a try—you’ll likely find it becomes a staple in your utility belt.