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