typescript

How to create a TS fuction that will allow using Array.from() with a union type parameter?


I have the following function that is erroring out on Array.from(list);

Type 'Element[]' is not assignable to type 'ArrayLike'

how to solve this and make the function work for both types ?

const reorderArray = (list: JSX.Element[] | number[], startIndex: number, endIndex: number): JSX.Element[] | number[] => {
    const result = Array.from(list);
    const [removed] = result.splice(startIndex, 1);
    result.splice(endIndex, 0, removed);
    return result;
  };


Solution

  • TypeScript doesn't automatically distribute analysis over union types. So Array.from(x) where x is of type T[] | U[] is not going to be T[] | U[]; the best you could hope for would be a result of type (T | U)[], and that would require specifying the type argument explicitly (because the compiler doesn't like to synthesize new union types during inference):

    const reorderArray = (
        list: number[] | JSX.Element[], startIndex: number, endIndex: number
    ) => {
        const result = Array.from<number | JSX.Element>(list);
        // --------------------> ^^^^^^^^^^^^^^^^^^^^^^
        // manually specify type argument
        const [removed] = result.splice(startIndex, 1);
        result.splice(endIndex, 0, removed);
        return result;
    }; 
    // return type is (number | JSX.Element)[]
    

    Maybe that's acceptable to you.


    If not and you want to distinguish the number[] output from the JSX.Element[] output then the recommended approach is use generics instead of a union:

    const reorderArray = <T extends JSX.Element | number>(
        list: T[], startIndex: number, endIndex: number
    ): T[] => {
        const result = Array.from(list);
        const [removed] = result.splice(startIndex, 1);
        result.splice(endIndex, 0, removed);
        return result;
    };
    

    Now the compiler understands all the operations in terms of the single generic type parameter T. When you call the function with just number[] or just JSX.Element[], the compiler can infer a type argument for T which will produce an array of a single narrowed type:

    const n = [1, 2, 3];
    const o = reorderArray(n, 1, 2);
    // const o: number[]
    
    const p = [<div />, <p />, <h1 />]
    const q = reorderArray(p, 1, 2);
    // const q: JSX.Element[]
    

    This still won't give you a union output for a union input:

    const r = Math.random() < 0.5 ? n : p;
    const s = reorderArray(r, 1, 2); // error, just like original Array.from error
    const t = reorderArray<number | JSX.Element>(r, 1, 2);
    // const t: (number | JSX.Element)[] // array of unions, not union of arrays
    

    but if you plan to call reorderArray() only on arrays of a known element type (and not a union of array types) then it doesn't matter.


    If you need to have the output be a union of arrays, then things get hairier. Ultimately the language does not support correlated union types very well. See microsoft/TypeScript#30581. The approaches to deal with that are usually either to use a type assertion and move on:

    const reorderArray2 = (
        list: number[] | JSX.Element[], startIndex: number, endIndex: number
    ): number[] | JSX.Element[] => {
        const result = Array.from<number | JSX.Element>(list);
        const [removed] = result.splice(startIndex, 1);
        result.splice(endIndex, 0, removed);
        return result as number[] | JSX.Element[]; // <-- assert
    };
    

    or write redundant code to force the compiler to deal with cases:

    const reorderArray3 = (
        list: number[] | JSX.Element[], startIndex: number, endIndex: number
    ): number[] | JSX.Element[] => {
        if (list.every((i): i is number => typeof i === "number")) {
            const result = Array.from(list);
            const [removed] = result.splice(startIndex, 1);
            result.splice(endIndex, 0, removed);
            return result;
        } else {
            const result = Array.from(list);
            const [removed] = result.splice(startIndex, 1);
            result.splice(endIndex, 0, removed);
            return result;
        }
    };
    

    or refactor to a particular flavor of generics as described in microsoft/TypeScript#47109 which is a lot of hard-to-explain hoop-jumping:

    interface TypeMap {
        n: number;
        e: JSX.Element
    }
    type ArrayType<K extends keyof TypeMap> = { [P in K]: Array<TypeMap[P]> }[K]
    const reorderArray4 = <K extends keyof TypeMap>(
        list: ArrayType<K> & {}, startIndex: number, endIndex: number
    ): ArrayType<K> & {} => {
        const result = Array.from(list);
        const [removed] = result.splice(startIndex, 1);
        result.splice(endIndex, 0, removed);
        return result;
    };
    const reorderArray5 = reorderArray4<keyof TypeMap>
    // const reorderArray5: (
    //   list: number[] | JSX.Element[], 
    //   startIndex: number, 
    //   endIndex: number
    // ) => number[] | JSX.Element[]
    

    That last function is exactly what you asked for, but the procedure for getting the compiler to follow the logic is so tortured that I wouldn't recommend it unless your use cases require it.

    Playground link to code