Flatten nested array with type inference

Viewed 759

I am trying to create a function that can flatten nested arrays in typescript.

So far I have this:

function flattenArrayByKey<T, TProp extends keyof T>(array: T[], prop: TProp): T[TProp] {
    return array.reduce((arr: T[TProp], item: T) => [...arr, ...(item[prop] || [])], []);
}

The array.reduce in there does exactly what I want as is, but I can't get the generics to play along nicely with what I want. I think my problem is that item[prop] returns any since it has no way of inferring that item[prop] returns T[TProp].

What I'm aiming for is a function that can take this structure:

interface MyInterface {
    arrayProperty: string[];
    anotherArray: number[]
    someNumber: number;
}

const objectsWithNestedProperties: MyInterface[] = [
    {
        arrayProperty: ['hello', 'world'],
        anotherArray: [1, 2],
        someNumber: 1,
    },
    {
        arrayProperty: ['nice', 'to'],
        anotherArray: [3, 4],
        someNumber: 2,
    },
    {
        arrayProperty: ['meet', 'you'],
        anotherArray: [5, 6],
        someNumber: 3,
    },
];

and return an array that contains the contents of all the nested array.

const result = flattenArrayByKey(objectsWithNestedProperties, 'arrayProperty');

result should look like ['hello', 'world', 'nice', 'to', 'meet', 'you']

Basically I am looking for SelectMany from C#'s linq.

2 Answers

Note: The following answer was tested on TS3.5 in --strict mode. Your mileage may vary if you use other versions or compiler flags.


How about this:

function flattenArrayByKey<K extends keyof any, V>(array: Record<K, V[]>[], prop: K): V[] {
    return array.reduce((arr, item) => [...arr, ...(item[prop] || [])], [] as V[]);
}

You have to tell the compiler that T[TProp] was going to be an array. Instead of trying to go that route, I have the generics be K (which you were calling TProp) and V, the element type of the array property at array[number][K]. Then you can type array as Record<K, V[]>[] instead of T[] (A Record<K, V[]> is an object whose property at key K is of type V[]). And it returns a V[].

Now the compiler understands what you're trying to do, although you do need to tell it that the initial empty array as the second parameter to reduce is supposed to be a V[] (hence [] as V[]).

And that should work how you want. Hope that helps; good luck!

Link to code

Update: the above seems not to infer well when the object has arrays of different types. You might find yourself having to type it explicitly like flattenArrayByKey<"anotherArray", number>(objectsWithNestedProperties, "anotherArray") which is redundant and annoying.

The following is a more complicated signature but it has better inference and better IntelliSense suggestions:

type ArrayKeys<T> = { [K in keyof T]: T[K] extends any[] ? K : never }[keyof T];

function flattenArrayByKey<T extends Record<K, any[]>, K extends ArrayKeys<T>>(
  array: T[],
  prop: K
): T[K][number][] {
  return array.reduce(
    (arr, item) => [...arr, ...(item[prop] || [])],
    [] as T[K][number][]
  );
}

It should behave the same in terms of the inputs and outputs, but if you start typing

const result = flattenArrayByKey(objectsWithNestedProperties, "");
// put cursor here and get intellisense prompts (ctrl-spc) ---^

It will suggest "arrayProperty" (and now "anotherArray") as the second parameter, since only arrayProperty (and "anotherArray") is suitable for reducing like that.

Hope that helps again. Good luck!

Link to code

Turns out ESNext has Array.flatMap which does exactly what I want.

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/flatMap

Syntax

var new_array = arr.flatMap(function callback(currentValue[, index[, array]]) {
   // return element for new_array
}[, thisArg])

callback Function that produces an element of the new Array, taking three arguments:

currentValue The current element being processed in the array.

index (Optional) The index of the current element being processed in the array.

array (Optional) The array map was called upon.

thisArg (Optional) Value to use as this when executing callback.

To use it, I needed to add esnext.array to my lib in tsconfig.json:

{
    "compilerOptions": {
         "lib": ["es2018", "dom", "esnext.array"]
     }
}

It does exactly what I want:

objectsWithNestedProperties.flatMap(obj => obj.arrayProperty)
// returns ['hello', 'world', 'nice', 'to', 'meet', 'you']

NB: This isn't supported by IE, Edge, and Samsung Internet.

Related