typescripttypescript-typingstypescript-generics

Unable to initialize object property to an empty array in TypeScript despite property seeming to be of type array


I am trying to write a method in TypeScript to flatten and pick elements of paginated outputs. A demonstrative example is:

const paginatedOutput: { results: number[], page: number, meta: string[] }[] = [{ page: 1, results: [1, 2, 3], meta: ['a', 'b', 'c'] }, { page: 2, results: [3, 4, 5], meta: ['d', 'e', 'f'] }];
 
flattenAndPick(paginatedOutput, ['results', 'meta']);
 
// Output:
//
// {
//    results: [1, 2, 3, 4, 5, 6],
//    meta: ['a', 'b', 'c', 'd', 'e', 'f']
// }

So the method flattenAndPick concatenates the array-type elements of objects inside an array.

The generic types and method I've come up with so far to solve this problem are:

/**
 * Generic type which takes an object `T` and a type `K` and returns a type
 * which is an object with only those properties of `T` which are of type `K`.
 *
 *
 * Example usage:
 * ```
 * type A = { a: string, b: number };
 *
 * type B = PickByType<A, number>
 *
 * const b: B = { a: '1', b: 2 } // Error
 *
 * const b2 = { b: 2 } // Allowed
 * ```
 */
type PickByType<Obj, Type> = {
  [key in keyof Obj as Obj[key] extends Type | undefined ? key : never]: Obj[key];
};

This generic type will be used to extract those properties from the underlying array objects which are themselves arrays, as these represent the results of the pagination to later be concatenated together.

The method for doing the picking and concatenating is:

/**
 * Flatten arrays of repeated objects which themselves contain arrays, and pick and concatenate
 * data in those arrays.
 *
 * Use case: Picking data of interest from paginated outputs.
 *
 * Example usage:
 *
 * ```
 * const paginatedOutput = [{ page: 1, results: [1, 2, 3], meta: ['a', 'b', 'c'] }, { page: 2, results: [3, 4, 5], meta: ['d', 'e', 'f'] }];
 *
 * flattenAndPick(paginatedOutput, ['results', 'meta']);
 *
 * // Output:
 * //
 * // {
 * //    results: [1, 2, 3, 4, 5, 6],
 * //    meta: ['a', 'b', 'c', 'd', 'e', 'f']
 * // }
 * ```
 */
const flattenAndPick = <T extends object, K extends keyof PickByType<T, any[]>>(
  array: T[],
  properties: K[],
): Partial<PickByType<T, any[]>> => {

  // Initialise an object which is just the underlying array object with only its array-type
  // properties
  const pickedObject: Partial<PickByType<T, any[]>> = {};

  // Iterate over selected properties and initialise empty arrays for these properties
  properties.forEach((property) => {
    pickedObject[property] = [] as T[K]; // <-- Note the assertion here - want to remove this
  });

  
  array.forEach((element) => {
    properties.forEach((property) => {
      pickedObject[property] = (pickedObject[property] as any[]).concat(element[property]) as T[K]; // <-- and these, although I suspect its the same issue as above
    });
  });

  return pickedObject;
};

I'm struggling to remove the type assertions in the above method. The error without the first assertion is Type 'undefined[]' is not assignable to type 'T[K]'.

PickByType seems to work as expected:

type A = { a: string; b: number; c?: number; d: string[] };

type B = PickByType<A, any[]>;

type C = keyof B;

type D = { [key in C]: B[key] };

type E = C[];


const d: D = { a: '1' }; // Error

const d2: D = { b: 2, a: '1' }; // Error

const d3: D = {}; // Error

const d4: D = { b: 2: 'd': ['a'] }; // Error

const d5: D = { d: ['a'] }; // Fine


const e: E = ['d']; // Fine

const e1: E = ['a']; // Error

So why can I not initialise pickedObject[property] = [] in flattenAndPick? It seems to me that T[K] should extend any[]. Any links to docs instructive of the underlying issue would also be appreciated.


Solution

  • The reason why TypeScript is complaining is that while T[K] is known to be assignable to any[], you can't necessarily assign a value of type [] to T[K]. The assignment direction is backwards there. Maybe T[K] is a tuple type with a fixed length:

    const entries = Object.entries({ a: 1, b: 2, c: 3 }).map(x => ({ x }));
    const flattened = flattenAndPick(entries, ["x"]);
    const len = flattened.x?.length
    //    ^? const len: 2 | undefined
    console.log(len) // 6, not 2 
    

    Here entries is an array of objects, each of which has an x property that's a pair (a tuple of length 2). And so TypeScript thinks flattened has an x property which, if defined, is also a pair. But of course it isn't.

    Or maybe T[K] has additional properties that a plain array does not have:

    const v = flattenAndPick(
        [{ a: Object.assign([1, 2, 3], { prop: "hello" }) }],
        ["a"]
    ).a;
    

    With your typings, the type of v is supposedly (number[] & { prop: string }) | undefined. You've told TypeScript that the a property of the result of flattenAndPick(), if it's defined will have a prop property of type string. So the following code compiles with no problem:

    const w = v?.prop.toUpperCase(); 
    //    ^? const w: string | undefined
    

    But of course there's a runtime error, because v.prop does not exist.

    In both cases you've assigned [] somewhere TypeScript cannot be sure it applies, and TypeScript was correct to complain about it. This might turn out not to be likely in practice, but that's what's going on. If you want to just leave it alone and not worry about it, then you should just use type assertions as you've been doing.


    On the other hand if you want types TypeScript considers more accurate, you could do it this way:

    const flattenAndPick = <T extends object, K extends keyof T>(
        array: ({ [k: string]: any } & { [P in K]: T[P][] })[],
        properties: K[],
    ): { [P in K]?: T[P][] } => {
    
        const pickedObject: { [P in K]?: T[P][] } = {};
    
        properties.forEach((property) => {
            pickedObject[property] = [];
        });
    
        array.forEach((element) => {
            properties.forEach((property) => {
                pickedObject[property] = (
                    pickedObject[property]!).concat(element[property]);
            });
        });
    
        return pickedObject;
    };
    
    const ret = flattenAndPick([{ a: "", b: [1, 2, 3] }, { b: [4] }], ["b"]);
    

    Here the generic T type corresponds to the element types of the arrays inside array's properties. So if array is [{ a: "", b: [1, 2, 3] }, { b: [4] }], then T should be something like {b: number}.

    Then array is a mapped type over T, so that each property of T is made into an array in array. Also, array's type is intersected with {[k: string]: any}, a special-cased type (see this comment in microsoft/TypeScript#41746) to match anything. I do that so we don't trigger excess property checking. We want a in [{ a: "", b: [1, 2, 3] }, { b: [4] }] to simply be ignored, not flagged as a warning.

    Anyway, now there's very little asserting going on. We know that [] is a valid value of type T[P][] or T[K][] because the "array" part of the type is not generic and cannot magically turn out to be narrower than an array. Only the element type is generic, and since we're just copying those and not trying to synthesize them, everything's good.

    There's still one assertion, a non-null assertion (!) on pickedObject[property]. That's because TypeScript cannot follow the logic that says that after the first forEach() call, all arrays will be initialized. If you want to avoid that you can do this:

    const flattenAndPick = <T extends object, K extends keyof T>(
        array: ({ [k: string]: any } & { [P in K]: T[P][] })[],
        properties: K[],
    ): { [P in K]?: T[P][] } => {
        const pickedObject: { [P in K]?: T[P][] } = {};
        array.forEach((element) => {
            properties.forEach((property) => {
                pickedObject[property] = (
                    pickedObject[property] ?? []
                ).concat(element[property]);
            });
        });
        return pickedObject;
    };
    

    so that the initializing happens at the same place as the concatenation.

    Playground link to code