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