1

I am trying to look for a way to omit n number of parent and nested properties in an interface. In the below example, I am trying to omit a parent property and two nested properties. The Omit type only goes one level deep. I was able to find something close, but it still didn't work:


interface IFirstObject {
  id: string;
  revisionId: string;
  someProperty: ISecondObject;
}

interface ISecondObject {
  somePropertyToKeep: string;
  firstPropertyToOmit: string;
  secondPropertyToOmit: string;
}

/// How can we create a type to behave like the below result I am looking for?
type SomeNestedOmitType<TEntity, TOmissions> = {...}

// Desired Result
const myObject: SomeNestedOmitType<IFirstObject, "id" | "someProperty.firstPropertyToOmit" | "someProperty.secondPropertyToOmit"> = {
  revisionId: "some revision id",
  someProperty: {
    somePropertyToKeep: "some property to keep"
  }
}

Is there a way in TypeScript to do this?

I am using TypeScript 5.

Agrejus
  • 722
  • 7
  • 18
  • 2
    These sorts of nested type manipulations invariably have bizarre edge cases (e.g., what do you want to see with index signatures, optional properties, unions, array types, functions, etc), so any solution you use should be thoroughly tested against use cases you care about. Does [this approach](https://tsplay.dev/Wvqgkm) meet your needs? If so, I'll write up an answer explaining; if not, please [edit] the code in the question to show a use case it doesn't work for. – jcalz Mar 22 '23 at 20:33
  • @jcalz Your approach is exactly what I am looking for! – Agrejus Mar 23 '23 at 01:29
  • So you've tested it against your use cases? I'm happy to write up an answer but I really don't want to have to re-write one, and with deep types like these, "it works except for one little thing" often requires a complete refactoring. Should I proceed, or wait for you to test it further? – jcalz Mar 23 '23 at 01:39
  • I tested it against my used cases, yes. Works as expected, didn't find any edge cases. I am only using nested objects with property types of `string` or `number`, no array's or anything. Confirmed the dot notation works as expected too. – Agrejus Mar 23 '23 at 03:32

1 Answers1

1

The solution I present below works as expected for the example code in the question. Possible future readers should take care: in my experience, such deeply nested type mappings tend to have bizarre edge cases that, if unacceptable, can sometimes require a complete refactoring to resolve. I'm not going to worry about these here.


Here's one possible implementation of NestedOmit<T, K>:

type NestedOmit<T, K extends PropertyKey> = {
    [P in keyof T as P extends K ? never : P]:
    NestedOmit<T[P], K extends `${Exclude<P, symbol>}.${infer R}` ? R : never>
} 

This uses key remapping to suppress any keys of T that appear directly in K; this part is just an alternative to the "normal" Omit, as described in microsoft/TypeScript#41383, and shown here:

type NormalOmit<T, K extends PropertyKey> = { 
  [P in keyof T as P extends K ? never : P]: T[P] 
}

The difference between Omit<T, K> and NestedOmit<T, K> happens inside the mapped property value type and not the keys. That property type is equivalent to NestedOmit<T[P], StripKey<K, P>> where StripKey<K, P> is defined as

type StripKey<K extends PropertyKey, P extends PropertyKey> =
    K extends `${Exclude<P, symbol>}.${infer R}` ? R : never

Since NestedOmit's properties are written in terms of NestedOmit, it's a recursively defined type. The idea of StripKey<T, P> is to trip the current key P and a dot off the beginning of each union member of K, or suppress the member entirely if it doesn't start with P and a dot. It uses a distributive conditional type to operate on each union member of K independently, and template literal type inference to parse the string. Let's see just this part in action:

type Demo = StripKey<"a.b" | "a.c.d" | "e" | "e.f" | "e.g", "a">;
// type Demo = "b" | "c.d"

You can see that, if you were calling NestedOmit<{a: XYZ}, K> with K as "a.b" | "a.c.d" | "e" | "e.f" | "e.g", "a", then the a property would end up being NestedOmit<XYZ, "b" | "c.d">.


So that's how it works. Let's test it on your example:

interface IFirstObject {
    id: string;
    revisionId: string;
    someProperty: ISecondObject;
}

interface ISecondObject {
    somePropertyToKeep: string;
    firstPropertyToOmit: string;
    secondPropertyToOmit: string;
}

type N = NestedOmit<IFirstObject,
    "id" | "someProperty.firstPropertyToOmit" | "someProperty.secondPropertyToOmit"
>;
/* type N = {
    revisionId: string;
    someProperty: NestedOmit<ISecondObject, 
      "firstPropertyToOmit" | "secondPropertyToOmit"
    >;
} */

That type is correct, but the display might not be what you want to see, since it's explicitly written in terms of NestedOmit. We can use a technique described at How can I see the full expanded contract of a Typescript type? to make NestedOmit expand its type fully, if it matters:

type NestedOmit<T, K extends PropertyKey> = {
    [P in keyof T as P extends K ? never : P]:
    NestedOmit<T[P], K extends `${Exclude<P, symbol>}.${infer R}` ? R : never>
} extends infer O ? { [P in keyof O]: O[P] } : never;

The ⋯ extends infer O ? { [P in keyof O]: O[P] } : never is the part that does it. Now we see:

type N = NestedOmit<IFirstObject,
    "id" | "someProperty.firstPropertyToOmit" | "someProperty.secondPropertyToOmit"
>;
/* type N = {
    revisionId: string;
    someProperty: {
        somePropertyToKeep: string;
    };
} */

which is exactly what you wanted.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360