3

This question is an extension of the one found here.

I have an object:

type exampleType = {
    propertyOne: string
    propertyTwo: number,
    propertyThree: {
        propertyFour: string,
        propertyFive: Date,
        propertySix: boolean,
    }
}

I'm looking for a type that would validate a dot-notation like string to a path of either string or Date. In the example above, this would mean it the type compiles to:

propertyOne | propertyThree.propertyFour | propertyThree.PropertyFive

Using the question previously asked above, the following is possible:

type PathsToStringProps<T> = T extends string ? [] : {
    [K in Extract<keyof T, string>]: [K, ...PathsToStringProps<T[K]>]
}[Extract<keyof T, string>];

type Join<T extends string[], D extends string> =
    T extends [] ? never :
    T extends [infer F] ? F :
    T extends [infer F, ...infer R] ?
    F extends string ? 
    `${F}${D}${Join<Extract<R, string[]>, D>}` : never : string;  

type Path = Join<PathsToStringProps<exampleType>, ".">

I'm trying to make the above solution generic, so that I could give Path two generic arguments: T, which would represent exampleType here, and V, which would be string|Date in my example above.

When I tried making exampleType generic:

type Path<T> = Join<PathsToStringProps<T>, ".">

I got this error: Excessive stack depth comparing types 'PathsToStringProps<T>' and 'string[]'.ts(2321)

Which I was able to solve by specifying that T must represent a Key-Value object:

type Path<T extends {[key: string]: any}> = Join<PathsToStringProps<T>, ".">

Moving on to restricting the type of value to path points to:

type PathsToStringProps<T, V> = T extends (V) ? [] : {
    [K in Extract<keyof T, string>]: [K, ...PathsToStringProps<T[K], V>]
}[Extract<keyof T, string>];

type Join<T extends string[], D extends string> =
    T extends [] ? never :
    T extends [infer F] ? F :
    T extends [infer F, ...infer R] ?
    F extends string ? 
    `${F}${D}${Join<Extract<R, string[]>, D>}` : never : string;  

type Path<T extends {[key: string]: any}, V> = Join<PathsToStringProps<T, V>, ".">

But I get an error:

error

Which disappears if I remove the generic argument V from Path, but keep it in PathsToStringProps:

type Path<T extends {[key: string]: any}> = Join<PathsToStringProps<T, string|Date>, ".">

Here's a TypeScript Playground of my final attempt at getting this to work.

Ali Bdeir
  • 4,151
  • 10
  • 57
  • 117
  • 1
    Composing such deeply nested recursive types is prone to such issues. I don't see why you want to go through the effort of getting tuples-of-strings only to immediately turn them into dotted versions. Why not do the dot version immediately, like [this](https://tsplay.dev/WGVvkm)? I could write this up as an answer if that meets your needs. If not, what am I missing? – jcalz Jul 28 '22 at 23:43
  • @jcalz That looks much cleaner! Feel free to post that as the answer, looks like it's what I need! – Ali Bdeir Jul 29 '22 at 00:10
  • @jcalz Now that I work with your solution, I do have one problem. Please check [this](https://tsplay.dev/wgZR6W) out. I specify that the value type must be `string|null`, but it seems like paths to either `string` or `null` or both work. – Ali Bdeir Jul 29 '22 at 00:28
  • But that's from *your* original version, as shown [here](https://tsplay.dev/Wk0P0w). All I changed was turning tuples into dotted strings. I mean, maybe you have different requirements, but then the premise of the question is flawed. What's the deal? – jcalz Jul 29 '22 at 03:14
  • @jcalz woops, you're right, this is what I want, I just hurt myself in my confusion. – Ali Bdeir Jul 29 '22 at 03:53
  • Okay I will write up an answer when I get the chance – jcalz Jul 29 '22 at 21:39

1 Answers1

4

Your approach, where PathsToProps<T, V> generates paths as tuples, and then where Join<T, D> concatenates the tuple elements to form dotted paths, is problematic for the compiler, since both PathToProps<T, V> and Join<T, D> are recursive conditional types, which don't always compose very nicely, and often run afoul of circularity guards or performance problems. Maybe you could tweak things so as to get that working, but it wouldn't be my first choice.

Instead, since it doesn't seem like you actually care about the tuples at all, you could just concatenate the strings directly inside PathsToProps<T, V>. In other words, instead of building up the paths first and then concatenating them later, you concatenate throughout the process.

It could look like this:

type PathsToProps<T, V> = T extends V ? "" : {
    [K in Extract<keyof T, string>]: Dot<K, PathsToProps<T[K], V>>
}[Extract<keyof T, string>];

type Dot<T extends string, U extends string> = 
  "" extends U ? T : `${T}.${U}`

The implementation of PathsToProps is very similar to yours, except that instead of explicitly dealing with empty tuples [] and prepending to into tuples via [K, ...PathsToProps<T[K], V>>], we explicitly use empty strings "" and concatenate them via Dot<K, PathsToProps<T[K], V>.

The Dot<T, U> type is just a shorthand for connecting a string T with an already-dotted-string U. You put a dot between them unless U is empty (this is technically wrong if you have any objects with an empty string as a key. You don't, do you? I hope not). The point is to make sure that you don't end up with a trailing dot that you need to remove (if you always joined with a dot, then you would get paths like "foo.bar.baz.").

Let's test it out:

type ExampleType = {
    propertyOne: string
    propertyTwo: number,
    propertyThree: {
        propertyFour: string,
        propertyFive: Date,
        propertySix: boolean,
    }
}

type StrOrDateEx = PathsToProps<ExampleType, string | Date>
// type StrOrDateEx = "propertyOne" | "propertyThree.propertyFour" | 
//  "propertyThree.propertyFive"

Looks good! And no recursion warnings.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360