typescripttuplestypescript-generics

How to type a function that unwraps each value of a tuple, to then return a tuple of the unwrapped values


Given I have a type Validated with the following form:

type Valid<T> = { isValid: true, value: T };
type Invalid = { isValid: false };
type Validated<T> = Valid<T> | Invalid;

const valid = <T>(value: T): Valid => ({ isValid: true, value });
const Invalid : Invalid = { isValid: false };

How would I go about typing the following function so that the output represents a tuple in the same form as the arguments list?

const zip = (...items) => 
  items.every(item => item.isValid) 
    ? { isValid: true, value: items.map(item => item.value) }
    : { isValid: false };

Examples of what I want the type system to produce:

const result = zip(valid(1), valid("a"), valid("b"), valid(true));
// result is marked as Valid<[number, string, string, boolean]> | Invalid;

If the form of the function I described does not allow for this, then is there a form that does?

I tried the following form:

const isValid = <T>(item: Validated<T>): item is Valid<T> => item.isValid;

const allValid = <T>(items: Validated<T>[]): items is Valid<T>[] =>
  items.every((item) => item.isValid);

const zip = <T>(...items: Validated<T>[]) : Validated<T[]> =>
  items.every((item) => item.isValid)
    ? { isValid: true, value: items.map((item) => item.value) }
    : { isValid: false };

However this erases the positional-preserving type of the tuple and outputs something more akin to Valid<[number | string | boolean]> | Invalid for the example from above.


Solution

  • You want zip's rest parameter items to be a tuple type so that TypeScript can keep track of the type of each passed-in argument. The approach you should take here is to make the function generic in the tuple type T of the output type argument. That is, you want zip(...items) to produce a result of type Validated<T>. And then you can make items a mapped tuple type that wraps each element of T with Validated, like this:

    const zip = <T extends any[]>(
        ...items: { [I in keyof T]: Validated<T[I]> }
    ): Validated<T> =>
        items.every(v => v.isValid) ?
            valid(items.map((item: Valid<any>) => item.value)) as Valid<T> :
            Invalid;
    

    So when you call zip() with an argument list of type, say, [Validated<X>, Validated<Y>, Validated<Z>], TypeScript can match it to { [I in keyof T]: Validated<T[I]> } and infer that T is [X, Y, Z]. And the return type of that call is Validated<T>.

    Note that inside the implementation, TypeScript isn't smart enough to understand that items.map(item => item.value) will be of type Valid<T> (at best it could verify Valid<T[keyof T]>[] or Valid<any>[]). The language isn't expressive enough to model how map() acts on tuples; see Mapping tuple-typed value to different tuple-typed value without casts. So I needed to use the type assertion as Valid<T>.

    Okay, let's test it out:

    const result = zip(valid(1), valid("a"), valid("b"), valid(true));
    //    ^? const result: Validated<[number, string, string, boolean]>
    
    const hmm = zip(valid(1), Math.random() < 0.5 ? valid("a") : Invalid, valid(false));
    //    ^? const hmm: Validated<[number, string, boolean]>
    
    const also = zip(Invalid);
    //    ^? const also: Validated<[unknown]>
    

    Looks good!


    Note that you haven't tried to ask TypeScript to detect whether the output will actually be Invalid or not. This is possible but it's a lot more complicated and possibly fragile. One approach would be

    declare const zip: <T extends Validated<any>[]>(...items: T) =>
        (Invalid extends T[number] ? Invalid : never) | (
            { [I in keyof T]: T[I] extends Invalid ? unknown : never }[number] extends never ?
            Valid<{ [I in keyof T]: T[I] extends Validated<infer U> ? U : never }> : never
        );
    

    which tries to tease apart whether any argument can be Invalid (in which case Invalid is part of the result) or whether any argument is definitely Invalid (in which case Valid<⋯> is not part of the result). I won't go into exactly how it works here because it's outside the scope of the question. But it produces these results:

    const result = zip(valid(1), valid("a"), valid("b"), valid(true));
    //    ^? const result: Valid<[number, string, string, boolean]>
    
    const hmm = zip(valid(1), Math.random() < 0.5 ? valid("a") : Invalid, valid(false));
    //    ^? const hmm: Invalid | Valid<[number, string, boolean]>
    
    const also = zip(Invalid);
    //    ^? const also: Invalid
    

    Playground link to code