typescriptrecursion

Inconsistent error behaviour on recursive Typescript type


I have a type that allows to describe any path through an object from the top (root) properties down to every child property in the object as a string array.

This is the type:

export type PredicateFunction<ArrayType> = (array: ArrayType, index?: number) => boolean;
export type IndexOrPredicateFunction<Type> = number | PredicateFunction<Type>;
export type StatePathKey = IndexOrPredicateFunction<any> | string;

export type StatePath<Obj, Path extends (string | IndexOrPredicateFunction<any>)[] = []> =
    object extends Required<Obj>
        ? Path
        : Obj extends object
            ? (Path |
                    // Check if object is array
                    (Obj extends readonly any[] ?
                        // ...when array only allow index or PredicateFunction
                        StatePath<Obj[number], [...Path, IndexOrPredicateFunction<Obj[number]>]>
                        // ...when object generate type of all possible keys
                        : { [Key in string & keyof Obj]: StatePath<Obj[Key], [...Path, Key]> }[string & keyof Obj]))
            : Path;

It works regarding type checks - it will detect and error out for all invalid paths but the error behavior makes me believe that something is not correct.

Assume this interface:

interface State  {
    test: {
        branch1: {
            branch1_1: {
                branch1_2: string;
            }
        },
        branch2: { 
            branch2_1: {
                branch2_2: string;
            }
        }
    }
}

Now I define those consts:

const test1: StatePath<State> = ['test', "branch1", "justAnyString"];  // <- Error on array entry
const test2: StatePath<State> = ['test', "branch1", "branch2_1"]; // <- Error on const

Why do I get different errors?

The behavior can be observed here:

Typescript playground


Solution

  • This can be demonstrated without the recursive type. Let's observe the behavior of a simple union of two tuple types:

    type X = ['test', 'branch1', 'branch1_1'] | ['test', 'branch2', 'branch2_1'];
    

    When you try to assign an invalid value with an element that doesn't match any of the types for that position in the union members of the type, you get an error for that element:

    const test1: X = ['test', "branch1", "justAnyString"]; // error!
    //                                   ~~~~~~~~~~~~~~~
    // Type '"justAnyString"' is not assignable to type '"branch1_1" | "branch2_1"'.
    

    That's because the error really and truly can be attributed to that element. On the other hand, when you assign an invalid value where each element matches at least one of the types for that position in the union members of the type, you get an error on the assignment instead:

    const test2: X = ['test', "branch1", "branch2_1"]; // error!
    //    ~~~~~ 
    // Type '["test", "branch1", "branch2_1"]' is not assignable to type 'X'.
    

    That's because the error cannot be isolated to a single element. It's a matter of opinion, not fact, whether "branch2_1" is wrong (and should be "branch1_1") or whether "branch1"is wrong (and should be"branch2"`). TypeScript doesn't try to determine which member of the union you were going for, it just says "this doesn't match" and moves along. You could argue that TypeScript should assume that earlier elements in the tuple are "more correct" than later ones, but someone else could argue differently.

    Anyway, that explains the difference in the error locations. I wouldn't say it's inconsistent; you have two different categories of mistake, and TypeScript displays an error in two different ways.


    Note that this is not specific to tuples. You get the same error behavior for arbitrary object types:

    type Y = { a: string, b: number } | { a: number, b: string }
    const y1: Y = { a: "abc", b: true }; // error!
    //                        ~
    const y2: Y = { a: "abc", b: "def" }; // error!
    //    ~~
    

    Again, for y1, the b property is definitely incorrect. No member of the Y union allows a boolean-valued property for b, so TypeScript warns on the b property. But for y2, no single member is definitely incorrect. The a property can be a string and the b property can be a string; they just can't both be a string at the same time. So TypeScript warns on the assignment, not the property.

    Playground link to code