skip to content

A codebase defines `type DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] }` and applies it to a type with `string[]`, `Date`, and callback properties. What goes wrong, and how do you harden it?

level: middleimportance: should knowfreq 50%

answer

  1. object means any non-primitive
  2. arrays keep array-ness, elements gain undefined
  3. methods survive, call signatures do not
  4. order the conditional branches narrow to broad
  5. Date and Map need explicit bail-outs

basics

~20 s

The extends object test also matches arrays, Dates, Maps and functions, so the helper recurses into them: array elements gain undefined, a Date becomes an object of optional methods, and a function loses its call signature. Fix it by bailing out on those shapes before the object branch.

solid answer

~50 s

The bug is that `extends object` is far broader than "plain object". Arrays match it, so `string[]` is mapped homomorphically and the optional modifier lands on the elements — you get `(string | undefined)[]`, and indexing it now yields a possibly-undefined value. `Date` matches it too, so it is rebuilt as `{ getTime?: ..., toISOString?: ... }`; a real Date still fits, but you can no longer call anything on it without a check, and an empty object literal now type-checks as a Date. Function properties match as well, and mapped types only copy properties — the call signature is dropped, so the callback stops being callable. The fix is an ordered conditional: match primitives, functions, then arrays and the built-ins you want preserved (Date, RegExp, Map, Set), and only fall through to the mapped-object branch last.

code

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

type DeepPartial<T> =
  T extends Primitive ? T
  : T extends (...args: never[]) => unknown ? T
  : T extends Date | RegExp ? T
  : T extends Map<infer K, infer V> ? Map<K, DeepPartial<V>>
  : T extends Set<infer U> ? Set<DeepPartial<U>>
  : T extends Array<infer U> ? Array<DeepPartial<U>>
  : T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
  : { [K in keyof T]?: DeepPartial<T[K]> };

interface Settings {
  createdAt: Date;
  tags: string[];
  onSave: (id: string) => void;
  theme: { colors: { bg: string; fg: string } };
}

const patch: DeepPartial<Settings> = {
  theme: { colors: { bg: "#000" } },
};

const withDate: DeepPartial<Settings> = { createdAt: new Date() };
withDate.createdAt?.toISOString();

go deeper

for a junior

Know that the helper makes every property optional at every depth, and that the naive one-liner misbehaves on arrays, Dates and callbacks rather than leaving them alone.

for a middle

Explain the mechanics: extends object matches any non-primitive, homomorphic mapping over an array keeps the array but makes elements possibly undefined, and mapped types drop call signatures. Be able to write the ordered bail-out version.

for a senior

Show the judgment: which built-ins you preserve, why branch order is load-bearing, how the same skeleton yields DeepReadonly with ReadonlyMap and ReadonlySet, and what exactOptionalPropertyTypes does to consumers of these types.

for a principal

Own the codebase-wide call: whether a shared deep helper lives in one blessed module with tests over its edge cases, and where the team is better served by explicit hand-written patch types than by a clever type that few can debug.

## The helper and its one flawed assumption ```typescript type DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K]; }; ``` This reads as "make every property optional, and if the property is an object, recurse". The recursion itself is fine — the alias refers to itself under a property, which the compiler resolves lazily. The flaw is the classifier. In TypeScript's type system `object` means *any non-primitive*: arrays, tuples, functions, class instances, `Date`, `Map`, `Set`, `RegExp`, `Promise` all satisfy `T extends object`. So "recurse into objects" quietly means "rebuild every non-primitive as a bag of optional properties". ## What happens to arrays `{ [K in keyof T]?: ... }` over an array type is a *homomorphic* mapped type: TypeScript preserves array-ness rather than producing an object with `length` and `push` keys. That sounds like good news, but the `?` modifier still applies to the elements. `DeepPartial<{ tags: string[] }>["tags"]` is therefore `(string | undefined)[]`. Consequences: pushing `undefined` into the array now compiles, and reading `tags[0].toUpperCase()` is an error because the element may be undefined. For an array of objects you get `(DeepPartial<User> | undefined)[]`, which is almost never what the author of the helper wanted — the intent was "the array may be missing", not "the array may contain holes". ## What happens to built-in classes `Date` is an object with only methods. The mapped type produces a structural type where every method is optional: ```typescript type BadDate = DeepPartial<Date>; // { toISOString?: () => string; getTime?: () => number; ... } ``` A real `Date` is still assignable to that, so nothing errors at the assignment site — the damage shows up later. Any use of the value needs a `?.` or a non-null assertion, and, in the other direction, `{}` now satisfies the property, so a bug that passes an empty object where a date belongs sails through. `Map`, `Set`, `RegExp` and `Promise` degrade the same way, and their methods are the *whole* value, so a mapped `Map` is unusable. ## What happens to functions A function type also satisfies `extends object`. Mapped types copy **properties**; they do not copy call or construct signatures. So a property typed `(id: string) => void` comes out as an object with `Function`'s members made optional and no way to invoke it: ```typescript type Handlers = { onSave: (id: string) => void }; type Bad = DeepPartial<Handlers>; // Bad["onSave"] is no longer callable ``` This is the failure that most often makes a team notice the helper is broken, because the error message ("This expression is not callable") points nowhere near the type alias. ## A hardened version Order matters: every branch is a subtype test, and the broadest test must come last. ```typescript type Primitive = string | number | boolean | bigint | symbol | null | undefined; type DeepPartial<T> = T extends Primitive ? T : T extends (...args: never[]) => unknown ? T : T extends Date | RegExp ? T : T extends Map<infer K, infer V> ? Map<K, DeepPartial<V>> : T extends Set<infer U> ? Set<DeepPartial<U>> : T extends Array<infer U> ? Array<DeepPartial<U>> : T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>> : { [K in keyof T]?: DeepPartial<T[K]> }; ``` Points to note: - The function branch precedes the object branch, otherwise callables are destroyed. - `Array<infer U>` is tested before `ReadonlyArray<infer U>`, because a mutable array also matches the readonly one and you would silently lose mutability. - Keys of a `Map` are usually left alone; making them partial rarely models anything real. - Because `T` is naked in these conditionals, the whole thing distributes over unions — `DeepPartial<A | B>` becomes `DeepPartial<A> | DeepPartial<B>`, which is normally what you want. ## The `DeepReadonly` twin The same skeleton with a different modifier gives `DeepReadonly`, and the built-in branches change to the read-only counterparts: ```typescript type DeepReadonly<T> = T extends Primitive ? T : T extends Map<infer K, infer V> ? ReadonlyMap<K, DeepReadonly<V>> : T extends Set<infer U> ? ReadonlySet<DeepReadonly<U>> : T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepReadonly<U>> : { readonly [K in keyof T]: DeepReadonly<T[K]> }; ``` `ReadonlyMap` and `ReadonlySet` exist in the standard library precisely for this. ## Two more things an interviewer may probe First, the optional modifier adds `undefined` to each property type — unless `exactOptionalPropertyTypes` is on, in which case the property may be *absent* but may not be explicitly set to `undefined`. Deep-partial values built by spreading often set keys to `undefined`, so turning that flag on can surface a wave of new errors in exactly this code. Second, none of this exists at runtime. `DeepPartial` describes what a merge function is allowed to receive; the merge itself still has to walk the object and decide what "missing" means. The type does not deep-merge anything.

  • Why does the hardened version test `Array<infer U>` before `ReadonlyArray<infer U>`?
    Every mutable array is assignable to `ReadonlyArray`, so if the readonly branch came first it would match `string[]` and the result would come back read-only. Conditional types pick the first matching branch, so the more specific shape has to be tested first — the same reason the function branch precedes the plain-object branch.
  • What does turning on exactOptionalPropertyTypes change for code that builds DeepPartial values?
    Without it, an optional property accepts an explicit `undefined`, so `{ theme: undefined }` compiles. With it, optional means the key may be absent, not present-and-undefined, so that same object is rejected. Code that spreads defaults and writes `undefined` for missing fields is exactly what starts erroring.
  • How would you keep class instances intact in a deep transformation?
    Add a branch that matches them before the object branch — either a union of the specific classes you care about, or a structural test such as `T extends { constructor: Function }` when the set is open. There is no general way to detect "is a class instance" in the type system, because the type layer is erased; you have to enumerate the shapes worth preserving.

saying these in an interview costs you the question

  • Assumes extends object only matches plain objects
  • Thinks arrays are skipped by a mapped type
  • Believes mapped types preserve call signatures
  • Says DeepPartial performs the merge at runtime
  • Puts the object branch before the function branch

context