Search code examples
typescript

How to get out of TS2615/circular reference situation with dotted property paths?


I am trying to use a typesafe solution for dotted property paths as suggested in an answer on another question: Typescript string dot notation of nested object

For this purpose, assume the following type declarations (taken over with small changes from the linked answer by jcalz):

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

type Join<T extends any[], 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 DottedLanguageObjectStringPaths<T extends object> = Join<PathsToStringProps<T>, ".">;

Then, look at the following custom types, which make use of the above DottedLanguageObjectStringPaths<T> type:

interface ITest {
    a: string;
}

interface IOptionBase<T extends object> {
    name: string;
    type: number;
    getFunc?: () => T;
    prop: DottedLanguageObjectStringPaths<T>;
}

interface IOption1<T extends object> extends IOptionBase<T> {
    type: 1;
}

interface IOption2<T extends object> extends IOptionBase<T> {
    type: 2;
}

type ActualOption<T extends object> = IOption1<T> | IOption2<T>;

type Wrapper<T extends object> = ActualOption<T> & ITest;

const x: Wrapper<ITest> = {
    type: 2,
    name: 'test',
    prop: 'a'
};

All of this works flawlessly. However, as soon as I add a self-reference to ITest:

interface ITest {
    a: string;
    parent?: ITest;
}

the literal assigned to x is marked as a compiler error:

TS2615: Type of property parent circularly references itself in mapped type

I'm not sure how to get out of this, as both the wrapping structure (which, in real life, is even a bit more complex than in this MWE) and the parent reference are mandatory requirements for us.

What makes this even stranger is that if I self-reference via an array, there seems to be no problem again:

interface ITest {
    a: string;
    parent?: ITest[];
}

How can I work around (or even just understand) this behavior by the TypeScript compiler?

Here is a Playground link of the whole thing.


Solution

  • You are nearly guaranteed to get a circularity error if you write a deeply recursive conditional type like PathsToStringProps<T> when T is itself recursive.

    What do you think DottedLanguageObjectStringPaths<ITest> should be? Conceptually it would be the infinite union type "a" | "parent.a" | "parent.parent.a" | "parent.parent.parent.a" | ⋯. But TypeScript can't represent an infinite union (unions in TypeScript can have thousands or tens of thousands of members, but that's somewhat smaller than infinity), nor can it recurse indefinitely (depending on the construction you might get a thousand levels deep, which is also not large enough).

    So your definition will run into troubles. Hopefully that explains the behavior, and why you see the error mentioning parent.


    As for how to fix it: you need to decide what you actually want DottedLanguageObjectStringPaths<T> to be when T is very deep. One approach is to just give it a depth limit. Maybe like this:

    type PathsToStringProps<T, D extends number = 10, A extends any[] = []> =
        A['length'] extends D ? never :
        T extends string ? [] : {
            [K in Extract<keyof T, string>]: [K, ...PathsToStringProps<T[K], D, [0, ...A]>]
        }[Extract<keyof T, string>];
    

    Here D is the intended depth limit (which I give as a default of 10), and A is an accumulator tuple which starts off empty. Every time we recurse into PathsToStringProps, we add an element to the accumulator (via variadic tuple types), and once the accumulator's length equals D, we bail out. (TypeScript can't natively increment numeric literal types, but it can manipulate tuples and check their lengths.)

    Then when you write DottedLanguageObjectStringPaths<T> in terms of PathsToStringProps<T> you'll get a default max depth of 10 (which you can change if you want):

    type ComeOn = DottedLanguageObjectStringPaths<ITest>
    /* type ComeOn = "a" | "parent.a" | "parent.parent.a" | "parent.parent.parent.a" | 
        "parent.parent.parent.parent.a" | "parent.parent.parent.parent.parent.a" | 
        "parent.parent.parent.parent.parent.parent.a" | 
        "parent.parent.parent.parent.parent.parent.parent.a" | 
        "parent.parent.parent.parent.parent.parent.parent.parent.a" */
    

    And now your problem goes away:

    const x: Wrapper<ITest> = {
        type: 2,
        name: 'test',
        prop: 'a',
        a: ""
    };
    

    Of course there are other possible approaches. One would be to try to detect a circularity and stop recursing if you see a type you've already seen. Maybe like:

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

    where U is the accumulated union of types already seen, and V is a copy of T (because T extends string ? ⋯ is a distributive conditional type and we don't want to distribute T for the outer check; I'm not going to digress further here... suffice it to say it's just hard to write robust recursive conditional types). When we recurse, we add the current copy of T to U. If the copy of T is ever assignable to U, then it means T is something we've already seen before (more or less) and we should bail out.

    This gives us

    type ComeOn = DottedLanguageObjectStringPaths<ITest>
    /* type ComeOn = "a" | "parent.a" */
    

    which recurses into parent exactly once.


    It's up to you whether you use one of those or some combination of those or something else. The exact details about when and how the recursion bails out are also up to you. Even with depth limits or circularity detectors you might write something that explodes in other slightly different situations. Deeply recursive conditional types are just prone to having bizarre edge cases. It's something to keep in mind when deciding whether you really want to use such a type in the first place.

    Playground link to code