Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

DeepMerge<> generic not supporting deep optional

Tags:

typescript

I have a DeepMerge<> generic below that is not supporting deep optional merges.

type DeepMerge<T, U> = [T, U] extends [object, object] ?
  {
    [K in keyof (U & Pick<T, Exclude<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;

I have a Person with a deep optional address.

type Person = {
  name: string,
  age: number,
  address: {
    line1: string,
    line2: string | null | number,
    zip: string | number,
    address?: {
      line1: string,
      line2: string | null | number,
      zip: string | number,
    },
  },
};

I want to overwrite the deep address and add burger.

type PersonOverwrites = {
  address: {
    pizzaDelivery: boolean,
    address?: {
      burger: boolean,
    },
  },
};

Here is is complaining:

const person: DeepMerge<Person, PersonOverwrites> = {
  name: 'Thomas',
  age: 12,
  address: {
    line1: 'hi',
    line2: 'hi',
    zip: 'hi',
    pizzaDelivery: true,
    address: {
      line1: 'hi',
      line2: 'hi',
      zip: 'hi',
      burger: true,
    },
  },
};

It should require line1, line2, zip, burger.

enter image description here

Type '{ line1: string; line2: string; zip: string; burger: true; }' is not assignable to type '{ burger: boolean; }'. Object literal may only specify known properties, and 'line1' does not exist in type '{ burger: boolean; }'.

It should be supporting { line1: string; line2: string; zip: string; burger: true; } but instead the entire deep object is being overwritten to { burger: boolean; }.

like image 805
ThomasReggi Avatar asked Dec 06 '25 18:12

ThomasReggi


1 Answers

Hmm, I think the problem here is that keyof ({a: string} | undefined) is showing up as never so as soon as the property is optional, DeepMerge can't recurse any farther down. There are probably other ways to fix this, but for a first shot, how about changing T and U to NonNullable versions of themselves, where NonNullable is defined in the standard library as

type NonNullable<T> = T extends null | undefined ? never : T;

Let's try it:

type DeepMerge<_T, _U, T= NonNullable<_T>, U= NonNullable<_U>> =
  [T, U] extends [object, object] ?
  {
    [K in keyof (U & Pick<T, Exclude<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;

Does that work? That infers the type of person as

const person: {
    address: {
        pizzaDelivery: boolean;
        address?: {
            burger: boolean;
            line1: string;
            line2: string | number | null;
            zip: string | number;
        } | undefined;
        line1: string;
        line2: string | number | null;
        zip: string | number;
    };
    name: string;
    age: number;
}

EDIT: it's also possible that all you want is to distribute across all unions, like this:

type DeepMerge<T, U> = T extends any ? U extends any ?
  [T, U] extends [object, object] ?
  {
    [K in keyof (U & Pick<T, Exclude<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 : never : never;

Maybe that works better for you?

Hope that helps. Good luck!

like image 178
jcalz Avatar answered Dec 09 '25 17:12

jcalz