typescripttype-inferenceextendskeyof

Expected 3 type arguments but got 1 but it should infer 2 types


I wondering how to correctly infer 2th and 3th template of my function

suppose a simple interface

interface ISome {
    a: string;
    b?: {
        c: string;
    };
}

The following works:

function pathBuilder<
    K1 extends keyof ISome,
    K2 extends keyof NonNullable<ISome[K1]>
>(p: K1, p2?: K2) {
    let res = String(p);
    if (p2) { res += "." + p2; }
    return res;
}

const pathTest = pathBuilder("b", "c"); // ---> "b.c" and intellisense works on parameters

but I need to generalize the function to work by specify another type ( I don't want to pass an object instance to specify the type )

So, the following does not work:

function pathBuilder<
    T,
    K1 extends keyof T,
    K2 extends keyof NonNullable<T[K1]>
>(p: K1, p2?: K2) {
    let res = String(p);
    if (p2) { res += "." + p2; }
    return res;
}

const pathTest = pathBuilder<ISome>("b", "c"); // ERROR: Expected 3 type arguments, but got 1.ts(2558)

It seems that the 2nd and 3rd template arguments of the function don't infer from the first, but it should because when I directly specified the type as T = ISome, it worked.

I'm not sure if there is some language keyword to make it work but the template should work exactly for that: specify an unknown type.

EDIT

Actually I found this way, but require extra coding I would avoid if possible

function pathBuilder<T>() {
    return <
        K1 extends keyof T,
        K2 extends keyof NonNullable<T[K1]>>(p: K1, p2?: K2) => {
        let res = String(p);
        if (p2) { res += "." + p2; }
        return res;
    };
}

const pathTest = pathBuilder<ISome>()("b", "c");

Solution

  • As of TS5.0 there is no partial type argument inference as requested in microsoft/TypeScript#10571 and microsoft/TypeScript#26242. Either you let the compiler try to infer all the type parameters, or you specify all the type parameters. (Well, there are default type arguments but that doesn't give you what you want: you want to infer the type parameters you leave out, not assign a default type to them). There have been several proposals to address this, at ms/TS#23696 and ms/TS#22368, but so far none have been fully approved or merged.

    For now, therefore, there are only workarounds. The two that I can think of are to use a dummy function parameter or to use currying.

    The dummy parameter version:

    function pathBuilderDummy<
        T,
        K1 extends keyof T,
        K2 extends keyof NonNullable<T[K1]>>(dummy: T, p: K1, p2?: K2) {
        let res = String(p);
        if (p2) { res += "." + p2; }
        return res;
    }
    
    const pathDummyTest = pathBuilderDummy(null! as ISome, "b", "c");
    

    Here we are doing what you said you didn't want to do... pass in a parameter of type T. But since it's just a dummy parameter and not used at runtime, it only matters what the type system thinks it is. The actual type of the value you pass in doesn't matter. So you can just pass it null and use a type assertion to choose T.

    The curried function solution:

    const pathBuilderCurry =
        <T>() => <
            K1 extends keyof T,
            K2 extends keyof NonNullable<T[K1]>>(p: K1, p2?: K2) => {
            let res = String(p);
            if (p2) { res += "." + p2; }
            return res;
        }
    
    const pathCurryTest = pathBuilderCurry<ISome>()("b", "c")
    

    Here you are returning a function that returns another function. The first function takes no value parameters but it does take the one type parameter you want to specify. It then returns a function where T is specified but the other type parameters are inferred.

    Neither solution is perfect, but they are the best we can do for now.