Search code examples
typescriptrecursion

Recursive type not working with optional properties


I have a type definition that allows me to navigate through an object via an array of strings / indices representing the keys of the object or nested arrays:

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 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<Object[Key], [...Path, Key]> }[string & keyof Obj]))
            : Path;

This works for e.g. this interface:

interface State1  {
    test: {
        nestedTest: boolean
    }
}

like this:

const t1: StatePath<State1> = ['test', 'nestedTest']; 

But it breaks as soon as I have an optional property:

interface State2  {
    test: {
        nestedTest?: boolean
    }
}

Any idea how to solve this? I already tried to use the -? on the type without success.

Find a sandbox for reproduction here: Typescript playground


Solution

  • The simplest fix for this particular issue would be to change your check from object extends Obj to object extends Required<Obj>. If Obj turns out to be a weak type, meaning it's an object type where all the properties are optional, then TypeScript will see the empty object type {} and the object type as assignable to it. So, for example, object extends {a?: string, b?: number} is true. But then the type will bail out where you don't want it to.

    There are various ways to proceed, but by using the Required<T> utility type you'd compare object to a version of the type where the optional properties are made required. And while object extends {a?: string, b?: number} is true, object extends Required<{a?: string, b?: number}> (a.k.a. object extends {a: string, b: number}) is false. And so now the type won't bail out unless Obj really is empty, or object, or unknown, etc.

    Playground link to code