typescripttypescript-genericsvariadic-tuple-types

TypeScript variadic tuple type inference issue, for function that runs a sequence of "Result"-returning functions


(Full code in TypeScript playground: link.)

I have a Result<T, E> type for computations that either succeed with a value of type T or fail with an error of type E.

type Result<T, E> =
    | {ok: true, value: T}
    | {ok: false, value: E};

I want to write a helper function that can run a list of Result-returning functions in sequence. If any function fails, stop processing and return the error. If all succeed, return a list of the success values.

Here's how you can implement that function specifically for lists of length three:

function sequenceFixed<T1, T2, T3, E>(fns: [() => Result<T1, E>, () => Result<T2, E>, () => Result<T3, E>]): Result<[T1, T2, T3], E> {
    const [f1, f2, f3] = fns;
    const r1 = f1();
    if (!r1.ok) return r1;
    const r2 = f2();
    if (!r2.ok) return r2;
    const r3 = f3();
    if (!r3.ok) return r3;
    return {ok: true, value: [r1.value, r2.value, r3.value]};
}

I want to use TypeScript 4.0's variadic tuple types to make this work for any length list. (The function body itself doesn't need to type check, just the call sites.)

Here are my two attempts:

declare function sequenceGeneric1<T extends Array<unknown>, E>(
    fns: Readonly<{[P in keyof T]: () => Result<T[P], E>}>,
): Result<[...T], E>;

declare function sequenceGeneric2<Fns extends Array<unknown>, E>(
    fns: Fns,
): Result<{[P in keyof Fns]: ExtractResultSuccessType<Fns[P]>}, E>;

type ExtractResultSuccessType<Fn> = Fn extends () => Result<infer T, unknown> ? T : unknown;

Calling those functions type-checks correctly if I explicitly pass type arguments, but I can't figure out how to get TypeScript to infer the type arguments.

declare function f1(): Result<string, string>;
declare function f2(): Result<boolean, string>;
declare function f3(): Result<number, string>;

function checkFixed() {
    return sequenceFixed([f1, f2, f3]);
}
function checkGeneric1() {
    return sequenceGeneric1([f1, f2, f3]);
}

function checkGeneric2() {
    return sequenceGeneric2([f1, f2, f3]);
}

function check() {
    // ok
    const c1: Result<[string, boolean, number], string> = checkFixed();

    // ERROR: Type 'Result<(string | number | boolean)[], unknown>' is not assignable to
    //        type 'Result<[string, boolean, number], string>'.
    const c2: Result<[string, boolean, number], string> = checkGeneric1();
    
    // ERROR: Type 'Result<(string | number | boolean)[], unknown>' is not assignable to
    //        type 'Result<[string, boolean, number], string>'.
    const c3: Result<[string, boolean, number], string> = checkGeneric2();
}

function checkGeneric1WithAnnontation(): Result<[string, boolean, number], string> {
    return sequenceGeneric1<[string, boolean, number], string>([f1, f2, f3]);
}

Solution

  • In cases like this it's definitely easier to go the route of your sequenceGeneric2, where you have a generic parameter directly corresponding to the type of the passed-in argument; after all, it's more straightforward for the compiler to infer the type T from a value of type T than it is for the compiler to infer the type T from a value of type Readonly<{[K in keyof T]: SomeFunction<T[K]>}>.

    In your case you were going in the right direction with Fns, but you were still expecting the compiler to infer E, which it's just not in a good position to do, for the same reason that you couldn't infer the T tuple very well in sequenceGeneric1.


    One more thing: if you pass an array literal into a function, there's a good chance that the compiler will infer an unordered array type for it instead of a tuple type. There are ways to give the compiler a hint that you'd prefer a tuple type to be inferred. One way is to use variadic tuple notation... so instead of writing fns: Fns, you can write fns: readonly [...Fns]. That doesn't make much difference in terms of what types fns can be, but it does cause the compiler to prefer tuple-typed Fns.


    So, to proceed: Let's have only one generic type parameter, Fns, and then make the compiler calculate both the T types and the E type in the output:

    declare function sequenceGeneric<Fns extends Array<() => Result<any, any>>>(
        fns: readonly [...Fns],
    ): Result<
      { [P in keyof Fns]: ExtractResultSuccessType<Fns[P]> },
      ExtractResultErrorType<Fns[keyof Fns]>
    >;
    

    where

    type ExtractResultSuccessType<Fn> = Fn extends () => Result<infer T, any> ? T : never;
    type ExtractResultErrorType<Fn> = Fn extends () => Result<any, infer E> ? E : never;
    

    I've changed unknown in the inferred type to any because it tends to be easier for the compiler to verify. unknown is a true top type which sometimes behaves strictly with covariance/contravariance in a way you don't really want.


    Let's see if it works:

    function checkGeneric() {
        return sequenceGeneric([f1, f2, f3]);
    }
    // function checkGeneric(): Result<[string, boolean, number], string>
    

    Looks good!


    Playground link to code