17

Is it possible to maintain type coverage on a function that deeply removes all instances of a key in an object?

My function looks like this.

function omitDeep<T extends object>(obj: T, key: string): TWithoutProvidedKey {
  return JSON.parse(
    JSON.stringify(obj),
    (key: string, value: any) => key === "__typename" ? undefined : value
  );
}

Is there any way to make TWithoutProvidedKey a reality?

TLadd
  • 6,488
  • 2
  • 32
  • 40

3 Answers3

28

This can easily be done, you just need to use mapped types to recurse down the properties:

type Primitive = string | Function | number | boolean | Symbol | undefined | null 
type DeepOmitHelper<T, K extends keyof T> = {
    [P in K]: //extra level of indirection needed to trigger homomorhic behavior 
        T[P] extends infer TP ? // distribute over unions
        TP extends Primitive ? TP : // leave primitives and functions alone
        TP extends any[] ? DeepOmitArray<TP, K> : // Array special handling
        DeepOmit<TP, K> 
        : never
}
type DeepOmit<T, K> = T extends Primitive ? T : DeepOmitHelper<T,Exclude<keyof T, K>> 

type DeepOmitArray<T extends any[], K> = {
    [P in keyof T]: DeepOmit<T[P], K>
}
type Input =  {
    __typename: string,
    a: string,
    nested: {
        __typename: string,
        b: string
    }
    nestedArray: Array<{
        __typename: string,
        b: string
    }>
    nestedTuple: [{
        __typename: string,
        b: string
    }]
}

type InputWithoutKey = DeepOmit<Input, '__typename'>

let s: InputWithoutKey = {
    a: "",
    nested: {
        b:""
    },
    nestedArray: [
        {b: ""}
    ],
    nestedTuple: [
        { b: ""},
    ]
}

Just a caveat, this works on 3.4, the handling of mapped types on arrays and tuples has changed recently, so depending on version you might need to handle arrays as a special case.

Titian Cernicova-Dragomir
  • 230,986
  • 31
  • 415
  • 357
  • 1
    Really great solution! I am running into one issue though where it looks like `DeepOmit` turns optional properties into required ones. Is there any way to prevent that? The below code snippet complains that `test` does not have the `world` property type Input = { __typename: string, hello: string world?: string } type InputWithoutKey = DeepOmit let test: InputWithoutKey = { hello: "hello", } – TLadd Apr 25 '19 at 18:43
  • Another issue I found was that `DeepOmit` is not equivalent to `string`, so if you try to pass a value that ran through `DeepOmit` into a function that accepts a `string` it will raise a typerror: type OmitString = DeepOmit let y: OmitString = "great" function cool(z: string) { return z } cool(y) – TLadd Apr 25 '19 at 19:02
  • Expanding on the last comment, it's an issue that comes up when working with an array of strings. This results in a type error because it is applying DeepOmit to type string, which doesn't equal type string. type Type1 = { __typename: string, arr: string[] } type TypeOmit = DeepOmit let x: TypeOmit = { arr: ["great", "breat"] } let z: string[] = x.arr – TLadd Apr 25 '19 at 19:17
  • 1
    @TLadd edited, fixed your issues, let me know if you find anything else, glad to see the type is getting good use :) – Titian Cernicova-Dragomir Apr 25 '19 at 19:57
  • 4
    Might just be me, but this isn't working for me on TS 3.5. – jmealy Jan 18 '20 at 17:55
12

The answers here were inspiring. I had some small issues with TypeScript 4.0 that I was able to work out. I'm maintaining it as a gist: https://gist.github.com/ahuggins-nhs/826906a58e4c1e59306bc0792e7826d1. Hope this helps some people, especially those wanting to deal with Partial utility in a deep omit.

/** Union of primitives to skip with deep omit utilities. */
type Primitive = string | Function | number | boolean | Symbol | undefined | null

/** Deeply omit members of an array of interface or array of type. */
export type DeepOmitArray<T extends any[], K> = {
    [P in keyof T]: DeepOmit<T[P], K>
}

/** Deeply omit members of an interface or type. */
export type DeepOmit<T, K> = T extends Primitive ? T : {
    [P in Exclude<keyof T, K>]: //extra level of indirection needed to trigger homomorhic behavior
        T[P] extends infer TP ? // distribute over unions
        TP extends Primitive ? TP : // leave primitives and functions alone
        TP extends any[] ? DeepOmitArray<TP, K> : // Array special handling
        DeepOmit<TP, K>
        : never
}

/** Deeply omit members of an array of interface or array of type, making all members optional. */
export type PartialDeepOmitArray<T extends any[], K> = Partial<{
    [P in Partial<keyof T>]: Partial<PartialDeepOmit<T[P], K>>
}>

/** Deeply omit members of an interface or type, making all members optional. */
export type PartialDeepOmit<T, K> = T extends Primitive ? T : Partial<{
    [P in Exclude<keyof T, K>]: //extra level of indirection needed to trigger homomorhic behavior
        T[P] extends infer TP ? // distribute over unions
        TP extends Primitive ? TP : // leave primitives and functions alone
        TP extends any[] ? PartialDeepOmitArray<TP, K> : // Array special handling
        Partial<PartialDeepOmit<TP, K>>
        : never
}>
aaron.huggins
  • 543
  • 3
  • 7
7

For those coming here with a later version of TS (I've tested this with TS3.8.3), you'll need to inline DeepOmitHelper from Titian's answer.

type Primitive =
  | string
  | Function
  | number
  | boolean
  | Symbol
  | undefined
  | null;

type DeepOmitArray<T extends any[], K> = {
  [P in keyof T]: DeepOmit<T[P], K>;
};

export type DeepOmit<T, K> = T extends Primitive
  ? T
  : {
      [P in Exclude<keyof T, K>]: T[P] extends infer TP
        ? TP extends Primitive
          ? TP // leave primitives and functions alone
          : TP extends any[]
          ? DeepOmitArray<TP, K> // Array special handling
          : DeepOmit<TP, K>
        : never;
    };
Benji Koltai
  • 71
  • 1
  • 1
  • I'm trying to adapt this to make a deep property optional (Partial), instead of omitting it, but am struggling. – Jazcash Nov 09 '20 at 17:51