Search code examples
typescripttypescript-generics

how infer method params with self object (literal)


How can I ensure that when I comment out line 37 with components: [1, 2], the argument c in available: (c) => { is not never but get type number?

The main issue is that if I comment out this line, all the others entries, even if they have different components:[...], become (c:never)=>{}.

I would also like the argument c in method to fallback to type number rather than never if the components property is not provided.

enum AAA {
    a1 = 'a1', a2 = 'a2', a3 = 'a3',
}
enum BBB {
    b1 = 'b1', b2 = 'b2', b3 = 'b3',
}


type TestData<T extends number = number> = {
    components?: T[];
    avaible: (c: T) => void; 
}

// Adjust the RR type to allow partial records for both AAA and BBB
type RR = Partial<Record<AAA, Partial<Record<BBB, number>>>>;


type Param<T extends RR> = {
    [KA in keyof T & AAA]?: {
        [KB in keyof T[KA] & BBB]?: TestData<Extract<T[KA][KB], number>>
    }
}

class Test<T extends RR> {
    constructor(
        public readonly data: Param<T>
    ) { }
}


// this is a db with literal entries
// i want pass to (c) type from .components or if not defined, juste use number
new Test(
    {
        [AAA.a1]: {
            [BBB.b1]: {
                components: [1, 2], // if i remove this, c should number and not never !?
                avaible: (c) => {
                    //    ^?
                }
            },
        },
        [AAA.a2]: {
            [BBB.b2]: {
                components: [5],
                avaible: (c) => {
                   //    ^?
                }
            },
              [BBB.b1]: {
                components: [8],
                avaible: (c) => {
                   //    ^?
                }
            },
        },
    }
);

playground


Solution

  • It looks like the main problem is that when T is constrained to RR, then as soon as you comment out the line, the inference fails and then T falls back to RR, at which point you get Param<RR> which is equivalent to {[K in AAA]?: {}} and then everything stops working. I'm not 100% sure why that's happening, but type inference is tricky.

    If you remove the constraint then things start to behave:

    type Param<T> = {
        [KA in keyof T]: {
            [KB in keyof T[KA]]: (
                TestData<Extract<T[KA][KB], number>>
            )
        }
    }
    
    class Test<T> {
        constructor(
            public readonly data: Param<T>
        ) { }
    }
    
    new Test(
        {
            [AAA.a1]: {
                [BBB.b1]: {
                    avaible: (c) => {
                        //    ^? (parameter) c: never
                    }
                },
            },
            [AAA.a2]: {
                [BBB.b2]: {
                    components: [5],
                    avaible: (c) => {
                        //    ^? (parameter) c: 5
                    }
                },
                [BBB.b1]: {
                    components: [8],
                    avaible: (c) => {
                        //    ^? (parameter) c: 8
                    }
                },
            },
        }
    );
    

    Now only the missing components has never inferred. If you want this to fall back to number instead (which shouldn't be necessary, you are always allowed to annotate (c: number) => {} if you want), then you can use an additional conditional type:

    type OrElse<T, D> = [T] extends [never] ? D : T;
    
    type Param<T> = {
        [KA in keyof T]: {
            [KB in keyof T[KA]]: (
                TestData<OrElse<Extract<T[KA][KB], number>, number>>
            )
        }
    }
    
    new Test(
        {
            [AAA.a1]: {
                [BBB.b1]: {
                    avaible: (c) => {
                        //    ^? (parameter) c: number
                    }
                },
            },
            [AAA.a2]: {
                [BBB.b2]: {
                    components: [5],
                    avaible: (c) => {
                        //    ^? (parameter) c: 5
                    }
                },
                [BBB.b1]: {
                    components: [8],
                    avaible: (c) => {
                        //    ^? (parameter) c: 8
                    }
                },
            },
        }
    );
    

    Looks good!


    At this point the only issue is that T can have all kinds of keys, not just AAA or BBB:

     new Test({x: {}}); // no error?!
    

    You can add back the constraint in a number of ways, possibly by mapping properties with unexpected keys to never, like:

    type Param<T> = {
        [KA in keyof T]: {
            [KB in keyof T[KA]]: (
                TestData<OrElse<Extract<T[KA][KB], number>, number>>
            ) & (KB extends BBB ? unknown : never)
        } & (KA extends AAA ? unknown : never)
    }
    

    which leaves the good cases alone but gives you errors on

    new Test({ x: {} }); // error!
    

    I'll say the exact way to prevent unexpected key/values is out of scope here, and that the main point is to remove the constraint extends RR and to use a conditional type on Extract<T[KA][KB], number> to cause never to become number.

    Playground link to code