10LOC
TypeScriptadvanced

Building DeepPartial<T> from scratch

Published December 22, 2026

export type DeepPartial<T> = T extends readonly unknown[]
  ? T
  : T extends object
    ? { [K in keyof T]?: DeepPartial<T[K]> }
    : T;

type Config = { server: { host: string; port: number }; features: { flags: string[] } };

export const mergeConfig = (base: Config, overrides: DeepPartial<Config>): Config => ({
  server: { ...base.server, ...overrides.server },
  features: { ...base.features, ...overrides.features },
});

What

DeepPartial<T> recursively makes every property of T optional, at every level of nesting — not just the top level, the way the built-in Partial<T> does. mergeConfig uses it to accept a partial, nested patch and merge it over a full Config.

Why it matters

Partial<Config> only makes server and features themselves optional — if you provide server, you're back to needing every one of its fields, because Partial doesn't recurse into nested object properties. That's the wrong shape for a config patch, where you want to override server.port alone without also restating server.host. DeepPartial fixes that by applying the same "make it optional" transform recursively to every nested object type it finds.

How it works

  • The T extends readonly unknown[] ? T : ... branch is checked first and left untouched — recursing into array element types here would produce Array<DeepPartial<Element>>, which is rarely what you want for a patch, since you don't usually want to merge two arrays element-by-element.
  • The T extends object branch covers plain nested objects like server and features: { [K in keyof T]?: DeepPartial<T[K]> } makes each property optional and recurses into it.
  • Anything that's neither an array nor an object (string, number, boolean, ...) hits the final : T branch unchanged — that's the recursion's base case.
  • mergeConfig spreads base's nested objects first, then spreads overrides' matching nested object over them, so provided fields win and omitted ones fall back to base.

Gotchas

  • T extends object is broader than "plain object" — it also matches Date, Map, class instances, and functions. Running DeepPartial over one of those makes its methods optional properties too, which type-checks but isn't meaningful. This implementation is fine for plain config-shaped data; a production-grade version usually special-cases Date and a few other built-ins to pass through unchanged, the same way arrays are handled here.
  • Because DeepPartial doesn't validate anything at runtime, mergeConfig's spread-based merge is doing the actual work — the type only describes what shape is allowed in, it doesn't perform the merge for you.

Related

  • Partial<T> — the shallow, built-in version this generalizes; reach for it when nesting isn't a concern.
  • Required<T> — the inverse transform, making optional properties mandatory; the same recursive trick applies if you need a DeepRequired<T>.