0

Context

I am trying to create a type-safe array of path segments to drill into an object. There are only 2 levels of depth to this particular interface that I'm trying to build the types against. I'll eventually use these segments to index into an object using dot notation, but for now, I'm just trying to make sure the types are constrained enough so that incorrect paths can't be added.

Example

interface Days {
    monday: string;
    tueday: string;
    wednesday: string;
    thursday: string;
    friday: string;
    saturday: string;
    sunday: string;
}

interface Weekend {
    saturday: string;
    sunday: string;
}

interface Example {
    days: Days;
    weekend: Weekend;
    year: string;
}

type KeysOfUnions<T> = T extends T ? keyof T : never;
type ExamplePath<T extends keyof Example = keyof Example> = [T, KeysOfUnions<Example[T]>?];

const correctlyErrors: ExamplePath = ["days", "test"]; // good - this errors so we're catching bad paths
const allowsCorrectPath: ExamplePath = ["days", "monday"]; // good - valid paths are accepted
const allowsIncorrectPaths: ExamplePath = ["weekend", "monday"]; // bad! - invalid combinations of paths are allowed

The types I've come up with so far are too loose, allowing for any permutation of path segments, even if those are impossible (i.e. ["weekend", "monday"]). I've tried to use a generic type variable with tuple types, by using the first path segment as type T to index into the Example type, before getting the keys of that.

The resulting type of this index approach is a union of:

(Days | Weekend | string)

Using keyof on this union type, resulted in the error

Type 'string' is not assignable to type 'never'.ts(2322)

So instead, a conditional type was used KeysOfUnions to fetch the keys of each union member, which resulted in overly loose typing as you can imagine.

Question

How can I infer the second element (path segment) of the tuple using the first element, ensuring that the type system enforces that only valid combinations of path segments can be added?

Edit 1: I'm also looking for a solution that allows for single segments if there are no more properties left to drill into. i.e. ["year"], ideally where the addition of any more elements to the array would break the types.

Edit 2: A maybe not so small addendum the example given was a fictional interface with 2 levels of nesting, however, I simplified its structure too much in my question, the actual interface has roughly 5 levels of nesting. For example, let's say that those Days and Weekend example interfaces were much deeper, with each day containing child objects and so on. I actually intended for a solution to type a tuple / array drilling down only for 2 levels of properties, ignoring deeper path segments. So maybe recursive approaches are out of the question for this constraint.

Jarvis Prestidge
  • 326
  • 1
  • 10

2 Answers2

2

This problem becomes quite simple when we break it down, and you were actually very close; you just needed to get each path separately, instead of putting them all into one tuple. Here I have chosen to use a mapped type (but you could possibly use distributive conditional types as well):

type KeyPaths<T> = {
    [K in keyof T]: T[K] extends Record<any, any> ? [K, ...KeyPaths<T[K]>] : [K];
}[keyof T];

type ExamplePath = KeyPaths<Example>;

Essentially we are, for each key of T, checking if T[K] is an object, and if so, we go further down into the object. Otherwise, we just give [K].

It works for the given examples, and also gives helpful errors:

Type '"test"' is not assignable to type '"monday" | "tueday" | "wednesday" | "thursday" | "friday" | "saturday" | "sunday" | undefined'.(2322)

and for the second:

Type '"weekend"' is not assignable to type '"days"'.(2322)

Playground


Adding a recursion limit to the type is also possible:

type KeyPaths<T, Depth extends unknown[]> = Depth extends [] ? [] : {
    [K in keyof T]: T[K] extends Record<any, any> ? [K, ...KeyPaths<T[K], Depth extends [...infer D, any] ? D : never>] : [K];
}[keyof T];

type ExamplePath = KeyPaths<Example, [0, 0]>;

Here we are just using a tuple's length to keep track of the recursions left until we are done.

If this doesn't appeal to you because it's a little dirty, you could also use numbers, and index into a tuple to "decrement" them:

type Decrement<X extends number> = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9][X];

type KeyPaths<T, Depth extends number> = Decrement<Depth> extends -1 ? [] : {
    [K in keyof T]: T[K] extends Record<any, any> ? [K, ...KeyPaths<T[K], Decrement<Depth>>] : [K];
}[keyof T];

type ExamplePath = KeyPaths<Example, 2>;

However, this is limited by the number of elements you include in the Decrement type. It is possible to remove this limitation with more type manipulation but that is out of the scope for this question and unnecessary.

kelsny
  • 23,009
  • 3
  • 19
  • 48
  • Heyo! thank you so much for the answer, technically this is also correct based on my poorly worded question, but as a final follow-up: is there a way to only drill down 2 levels of properties, for example, let's say that those `Days` and `Weekend` example interfaces were actually deeper nested objects, but I only wanted the path segments at this particular level. Hope this isn't too much – Jarvis Prestidge Sep 15 '22 at 03:57
  • @JarvisPrestidge Just added a few ways to add a recursion limit to the type. – kelsny Sep 15 '22 at 04:19
  • Thank you for all the effort. Definitely marking this as answered – Jarvis Prestidge Sep 15 '22 at 04:31
2

You want ExamplePath to be a distributive object type (as coined in ms/TS#47109) where you distribute the type [K, keyof Example[K]] over each K in the union keyof Example. It looks like this:

type ExamplePath = { [K in keyof Example]: [K, keyof Example[K]] }[keyof Example]
/* type ExamplePath = ["days", keyof Days] | ["weekend", keyof Weekend] | 
  ["year", number | typeof Symbol.iterator | "toString" | "charAt" | 
   "charCodeAt" | "concat" | ... 37 more ... | "padEnd"] */

That gives you the "days" and "weekend" behavior you want (not sure about "year", since the second element would be keyof string which is that nightmare of all string methods and apparent properties, but that's apparently what you're going for, so )

Anyway, let's test it:

const correctlyErrors: ExamplePath = ["days", "test"]; // error
const allowsCorrectPath: ExamplePath = ["days", "monday"]; // no error
const alsoErrors: ExamplePath = ["weekend", "monday"]; // error

Looks good.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Oh well, I guess if it's only two levels deep recursion is not needed :) – kelsny Sep 15 '22 at 03:36
  • Yeah, the question as asked is looking for a two-tuple specifically; otherwise it would be the [deep keyof question](https://stackoverflow.com/questions/58434389/typescript-deep-keyof-of-a-nested-object) I guess – jcalz Sep 15 '22 at 03:38
  • Hey, thanks for the response! I should have better defined my question, I was also looking for the possibility of something like `["year"]` only (since no other valid path segments exist), so I suppose tuple was incorrect language in this case, it's more like a variable length array . You're right those string methods are a bit of a nightmare, atm the compiler is forcing me into choosing one haha – Jarvis Prestidge Sep 15 '22 at 03:44