7

I want to write a type utility that omits fields recursively. Something that you would name and use like that OmitRecursively<SomeType, 'keyToOmit'>

I've tried to do it using mapped types + conditional typing but I stuck on the case when all required fields got typed correctly (hence field disappeared from nested type), but optional fields are ignored with that approach.

// This is for one function that removes recursively __typename field 
// that Appolo client adds
type Deapolify<T extends { __typename: string }> = Omit<
  { [P in keyof T]: T[P] extends { __typename: string } ? Deapolify<T[P]> : T[P] },
  '__typename'
>

// Or more generic attempt

type OmitRecursively<T extends any, K extends keyof T> = Omit<
  { [P in keyof T]: T[P] extends any ? Omit<T[P], K> : never },
  K
>

Expected behavior would be root and all nested keys that have a type with a key that should be recursively omitted is omitted. E.g

type A = {
  keyToKeep: string
  keyToOmit: string
  nested: {
    keyToKeep: string
    keyToOmit: string
  }
  nestedOptional?: {
    keyToKeep: string
    keyToOmit: string
  }
}

type Result = OmitRecursively<A, 'keyToOmit'>

type Expected = {
  keyToKeep: string
  nested: {
    keyToKeep: string
  }
  nestedOptional?: {
    keyToKeep: string
  }
} 

Expected === Result
Andrii Los
  • 486
  • 5
  • 8

2 Answers2

18

You don't call OmitRecursevly recursively, and I also would only apply the omit recursively if the property type is an object, otherwise it should mostly work:


type OmitDistributive<T, K extends PropertyKey> = T extends any ? (T extends object ? Id<OmitRecursively<T, K>> : T) : never;
type Id<T> = {} & { [P in keyof T] : T[P]} // Cosmetic use only makes the tooltips expad the type can be removed 
type OmitRecursively<T extends any, K extends PropertyKey> = Omit<
    { [P in keyof T]: OmitDistributive<T[P], K> },
    K
>

type A = {
    keyToKeep: string
    keyToOmit: string
    nested: {
        keyToKeep: string
        keyToOmit: string
    }
    nestedOptional?: {
        keyToKeep: string
        keyToOmit: string
    }
}

type Result = OmitRecursively<A, 'keyToOmit'>

Playground link

Edit: Updated to reflect addition of built-in Omit helper type. For older versions just define Omit.

Note Id is used mostly for cosmetic reasons (it forces the compiler to expand Pick in tooltips) and can be removed, it sometimes can cause problems in some corener cases.

Edit The original code did not work with strictNullChecks because the type of the property was type | undefined. I edited the code to distribute over unions. The conditional type OmitDistributive is used for its distributive behavior (we use it for this reason not the condition). This means that OmitRecursively will be applied to each member of the union.

Explanations

By default the Omit type does not work well on unions. Omit looks at a union as a whole and will not extract properties from each member of the union. This is mostly due to the fact that keyof will only return common properties of a union (so keyof undefined | { a: number } will actually be never, since there are no common properties).

Fortunately there is a way to drill into a union using conditional type. Conditional types will distribute over naked type parameters (see here for my explanation or the docs). In the case of OmitDistributive we don't really care about the condition (that is why we use T extends any) we just care that if we use conditional types T will in turn be each member in the union.

This means that these types are equivalent:

OmitDistributive<{ a: number, b: number} | undefined}, 'a'> = 
     OmitRecursively<{ a: number, b: number}, 'a'> | undefined 
Titian Cernicova-Dragomir
  • 230,986
  • 31
  • 415
  • 357
  • It doesn't work. I just checked that code a second ago. Can it be connected with compiler config somehow? – Andrii Los Feb 02 '19 at 09:16
  • @AndriiLos Works yeas, strict null checsk causes issues will fix in a second – Titian Cernicova-Dragomir Feb 02 '19 at 09:21
  • @AndriiLos fixed to work with strict null checks, as a side effect it will also drill into unions and remove the properties from each union member (this is probably a good thing IMO) – Titian Cernicova-Dragomir Feb 02 '19 at 09:30
  • amazing. It works! Question is. Can u maybe explain it in detail, step by step? Because I'm totally lost on OmitDistributive and others :) Also, why did you use different Omit and not the original one that TS has baked in? – Andrii Los Feb 02 '19 at 09:51
  • @AndriiLos Omit is not part of the standard library you probably have a library installed, I try to provide fully working code where possible :). I will try to add a couple of links to explain the distributive behavior of conditional types. If you have questions more question you can reach me on gitter :) – Titian Cernicova-Dragomir Feb 02 '19 at 09:54
  • 1
    @AndriiLos edited the answer, hope it's a bit more clear – Titian Cernicova-Dragomir Feb 02 '19 at 10:09
  • Works like a charm with Typescript 3.8.x. You are a wizard. Thank you!! – Mason Bourgeois Jun 08 '20 at 20:04
  • How to get rid of this type error on line 7 in the playground link? Type 'K' does not satisfy the constraint 'string | number | symbol'. – Rocaboca Jul 16 '20 at 14:27
  • @Rocaboca `Omit` was added to the base lib since this answer. Fixed (link works now). Thank you for bringing it to my attention. – Titian Cernicova-Dragomir Jul 16 '20 at 14:37
1

I wrote an extended post about this topic: Writing a Recursive Utility Type in TypeScript.

First, the code:

type UnionOmit<T, K extends string | number | symbol> = T extends unknown
  ? Omit<T, K>
  : never;
type NullUnionOmit<T, K extends string | number | symbol> = null extends T
  ? UnionOmit<NonNullable<T>, K>
  : UnionOmit<T, K>;
type RecursiveOmitHelper<T, K extends string | number | symbol> = {
  [P in keyof T]: RecursiveOmit<T[P], K>;
};
type RecursiveOmit<T, K extends string | number | symbol> = T extends {
  [P in K]: any;
}
  ? NullUnionOmit<RecursiveOmitHelper<T, K>, K>
  : RecursiveOmitHelper<T, K>;

const cleanSolarSystem: RecursiveOmit<SolarSystem, "__typename"> = {
  //__typename: "SolarSystem",
  id: 123,
  name: "The Solar System",
  star: {
    //__typename: "Planet",
    id: 123,
    inhabitants: null,
    name: "Sun",
    size: 9999,
  },
  planets: [
    {
      //__typename: "Planet",
      id: 123,
      name: "Earth",
      size: 12345,
      inhabitants: [
        {
          //__typename: "LifeForm",
          id: 123,
          name: "Human",
        },
      ],
    },
  ],
};

...and the playground link

All of this is required to cover the following cases:

  • Unions
  • Nullable Types (which seem like unions, but they're "sticky")
  • Functions and any other type that doesn't behave well when mapped. (Function lose their Callable signature)

It's largely equivalent to the other answer, but it has a slightly cleaner approach in my opinion.

Slava Knyazev
  • 5,377
  • 1
  • 22
  • 43