Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Typescript - type safe deep omit, or: how to list legal object paths

Alright, this is a long and very specific one. Would greatly appreciate any input.

I've written a recursive Omit type, which takes a type T and a tuple of strings (a 'path'), and indexes into T, removing the last item on the path and returning that type.

I've already looked at Deep Omit With Typescript - but that deals with recursively omitting the same key rather than traversing along a path.

I've also looked at Type Safe Omit Function but that deals with run-time and is about a one-level omit.

My implementation:

// for going along the tuple that is the path
type Tail<T extends any[]> = ((...args: T) => void) extends ((head: unknown, ...rest: infer U) => void) ? U : never;

type DeepOmit<T, Path extends string[]> = T extends object ? {
    0: Omit<T, Path[0]>;
    1: { [K in keyof T]: K extends Path[0] ? DeepOmit<T[K], Tail<Path>> : T[K] }
}[Path['length'] extends 1 ? 0 : 1] : T;

I want to make it so Path is constrained to be a valid traversal of T, something like:

Path = [K1, K2, K3, ..., K_n] such that:
K1 extends keyof T, K2 extends keyof T[K1], ... Kn extends keyof[T][K1]...[K_(n-1)]

First attempt

I've written a type that takes a type T and a path P and returns P if it's valid, or never otherwise:

type ExistingPathInObject<T, P extends any[]> = P extends [] ? P : {
    0: ExistingPathInObject<T[P[0]], Tail<P>> extends never ? never : P;
    1: never;
}[P[0] extends keyof T ? 0 : 1]

However, in DeepOmit's signature I can't enforce Path extends ExistingPathInObject<T,Path> as that is a circular dependency. I mention this attempt because there might be a way to circumvent the circularity and use this type to verify Path as a valid traversal of T.

Second Attempt

Because I can't seem to use Path to constrain itself, I instead tried to generate a union of all existing paths in a type and then require Path to extend that. The best I could come up with is this:

type Paths<T> = {
    0: { [K in keyof T]: T[K] extends object ? [K, Paths<T[K]>[keyof Paths<T[K]>]] : [K] }
    1: []
}[T extends object ? 0 : 1];

// an example type to test on
type HasNested = { a: string; b: { c: number; d: string } };

type pathTest = Paths<HasNested>;
//{
//  a: ["a"];
//  b: ["b", ["d"] | ["c"]];
//}

type pathTestUnion = Paths<HasNested>[keyof Paths<HasNested>]
// ["a"] | ["b", ["d"] | ["c"]]

This lets me match a path written as a tree: ['a'] extends pathTestUnion, and ['b', ['d']] extends pathTestUnion are both true. I have to add a [keyof T] to get the union and I can't put it in the type Paths itself because it's not recognized as valid.

Having done all of that, I'm now having difficulty rewriting DeepOmit to use this constraint. Here's what I've tried:

type Types<T> = T[keyof T];

type TypeSafeDeepOmit<T, Path extends Types<Paths<T>>, K extends keyof T> =
    Path extends any[] ?
    T extends object ?
    { // T is object and Path is any[]
        0: Omit<T, K>;
        1: { [P in keyof T]: P extends Path[0] ? TypeSafeDeepOmit<T[P], Path[1], Path[1][0]> : T[P] }
    }[Path['length'] extends 1 ? 0 : 1] :
    T : // if T is not object
    never; // if Path is not any[]

type TSDO_Helper<T, P extends Types<Paths<T>>> = P extends any[] ? P[0] extends keyof T ? TypeSafeDeepOmit<T, P, P[0]> : never : never;

This is ugly and uses a helper type to actually work. I also have to tell the compiler that P extends any[] and P[0] extends keyof T, even though that is exactly what Paths is meant to ensure. I'm also getting an error in the recursive call of TypeSafeDeepOmit using Path[1] -

Type 'any[] & Path' is not assignable to type '[]'.
        Types of property 'length' are incompatible.
          Type 'number' is not assignable to type '0'

I've fixed that by setting Path extends Types<Paths<T>> | [] but I'm not sure that's the right way to go about it.

In summary

So, is there any nicer way to enforce a valid path? Is it possible to also support a union of paths, so as to omit all of them? Right now the result I get is a union of the results of different omits.

like image 903
Ran Lottem Avatar asked Dec 16 '25 20:12

Ran Lottem


1 Answers

This is too long for a comment, but I don't know if it counts as an answer.

Here's a really yucky transformation (with many possible edge cases) from a union-of-omits to the multi-omit:

type NestedId<T, Z extends keyof any = keyof T> = (
  [T] extends [object] ? { [K in Z]: K extends keyof T ? NestedId<T[K]> : never } : T
) extends infer P ? { [K in keyof P]: P[K] } : never;

type MultiDeepOmit<T, P extends string[]> = NestedId<P extends any ? DeepOmit<T, P> : never>

The way it works (if it does work) is to take a union like {a: string, b: number} | {b: number, c: boolean} and only use the keys that exist in all of the constituents of the union: {b: number}. It's hard to get that to happen, and breaks optional properties and who knows what else.

Good luck, sorry for no great answer yet.

like image 96
jcalz Avatar answered Dec 19 '25 12:12

jcalz



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!