typescriptmapped-types

Preserve field optional-ness using conditional and mapped type


The following type removes desired properties from all nested depths of the input type:

type DeepRemoveProps<T, P extends string> = T extends object
  ? { [K in Exclude<keyof T, P>]: DeepRemoveProps<T[K], P> }
  : T;

For example:

type Input = {
  a: {
    b: string,
    c: {
      xx: 'removed',
      d: string
    }
  },
  yy: { removed: true },
  xx: 'im removed'
};
type Result = DeepRemoveProps<Input, 'xx' | 'yy'>;

In this case Result resolves to:

type Result = {
  a: {
    b: string,
    c: {
      d: string
    }
  }
};

But this type fails to preserve whether fields are optional!

For example, with this type:

type Input = {
  a: {
    b?: string,
    c: {
      xx: 'removed',
      d?: string
    }
  },
  yy: { removed: true },
  xx: 'im removed'
};

the result of DeepRemoveProps<Input, 'xx' | 'yy'> is exactly the same as above, with all properties required - for some reason, Input['a']['b'] and Input['a']['c']['d'] are not having their ? preserved. I was under the impression that the mapped type used in DeepRemoveProps would by default preserve whether or not a ? is present.

How can I implement DeepRemoveProps in such a way that it preserves whether fields are optional?


Solution

  • If you want a mapped type to preserve the optional and readonly modifiers for properties you're mapping over, you need to write a homomorphic mapped type (see What does "homomorphic mapped type" mean?) of the form {[⋯ in keyof T]: ⋯} for generic T, where in keyof appears directly. You have in Exclude<keyof which breaks it. (TypeScript needs to know what the original type is, in order to copy property modifiers. If it sees in keyof T it will understand you're copying from T. But if the key set after in is some arbitrary type that merely depends on keyof T somewhere, it can't be sure.)

    My suggestion would be to use key remapping with as and filter out keys in the as clause instead of the in clause. That is, use Exclude to act on K and not keyof T:

    type DeepRemoveProps<T, P extends string> = T extends object ? {
        [K in keyof T as Exclude<K, P>]: DeepRemoveProps<T[K], P>
    } : T;
    

    This maps any key K that overlaps with P to never, which has the effect of suppressing the key from the output. Now your code should behave as expected:

    type Result = DeepRemoveProps<Input, 'xx' | 'yy'>;
    /* type Result = {
        a: {
            b?: string | undefined;
            c: {
                d?: string | undefined;
            };
        };
    } */
    

    Playground link to code