DEV Community

Cover image for Extending the Array map() function
Ran Lottem
Ran Lottem

Posted on • Updated on

Extending the Array map() function

When writing TypeScript or JavaScript code, you'll come across a lot of code snippets like this:

const result_1 = myArray.map(x => doSomething(x));
const result_2 = myArray.map(x => x.property);
const result_3 = myArray.map(x => x.toString());

These lines of code are pretty similar - they produce an array from an input array. This is done by doing one of a few things on each element of the input array: calling a function with the element as the argument, accessing a property on the element, or calling one of the element's class methods. With a few funny exceptions, calling a function with the argument being the array element can be abbreviated like this:

const result_1 = myArray.map(doSomething);

If the function doSomething only takes one argument and does not reference a this object we would usually by fine using this shorter syntax. The other two examples above can't be abbreviated like that, and I'll show a [subjectively] elegant solution here.

Overloading Array.map()

Property Access

I want to be able to do the property access without that x => x.<prop> syntax. I want to achieve this:

const result_2 = myArray.map('property');
const result_2 = myArray.map('undefinedProperty'); // error here

The function and its type signature were actually the simpler part for me to implement, it's adding to the basic Array<T> type that took some trials to get to, but here's the result:

interface Array<T> {
    mapTo<K extends keyof T>(k: K): Array<T[K]>;
}

Array.prototype.mapTo = function <T, K extends keyof T>(this: T[], k: K) {
    return this.map(x => x[k]);
};

The use of the keyof keyword ensures that for an array with elements of type T we can only provide the name of one of T's properties. The x => x[k] syntax is used in the implementation, but we don't have to use it ever again. We can map as we always have, or use this overload in case we're only doing a property access.

Function Call

This one is slightly trickier, since the generic type parameter T isn't necessarily a function type. We want to be able to map from an array of functions to an array of results of those functions being called, but we can't simply go x => x(...args) for any old x of type T. To accomplish this, we'll introduce our own subtype of Array, the FunctionalArray (that is not to say ordinary arrays are dysfunctional, though ;)

class FunctionalArray<A extends any[], R> extends Array<(...args: A) => R> {
    mapToCall(...args: A): R[] {
        return this.map(x => x(...args));
        // consider the alternative:
        return this.map(function(x) { return x(...args); })
    }
}

A FunctionalArray is an Array where each element is a function, and has two type parameters - A for the function's argument types, and R for its return type. This lets us easily define mapToCall using those type parameters, and we're free to use the array elements as functions because we've defined this to be an array of functions of the appropriate types.

The next step is to use our new FunctionalArray class with our new Array<T>.mapTo so we can actually do something cool like this:

[1, 2, 3].map(x => x.toExponential()); // rewrite as:

[1, 2, 3].mapTo('toExponential').mapToCall();

To do this we can have mapTo return a FunctionalArray if it can:

interface Array<T> {
    mapTo<K extends keyof T>(k: K): T[K] extends (...args: infer A) => infer R ? FunctionalArray<A, R> : Array<T[K]>;
}

This return type means that for an array of type T where type T[K] is a function, mapTo will actually return the correct FunctionalArray with the relevant type parameters, or just an Array<T[K]> otherwise.

We can now replace mapping an array using a simple property access with mapTo and calling a method with mapTo followed by a mapToCall. An contrived example using an object literal with some method:

[{ omg(x?: number): number { return 2; } }].mapTo('omg').mapToCall(2); // optional argument can be provided
[{ omg(x?: number): number { return 2; } }].mapTo('omg').mapToCall(); // or omitted
[{ omg(x: number): number { return 2; } }].mapTo('omg').mapToCall(); // an error is produced if a required argument is not provided

Partial Function Application

I can imagine the function being called with mapToCall might require several arguments, which might not all be available at the location where the mapping is invoked. In this case we might consider providing some of the arguments and providing a FunctionalArray of curried functions. Consider this code:

type someFunctionType = (a: string, b: number, c: string, d: number) => string;

let func: someFunctionType;

// expected type: FunctionalArray<[string, number], string>
const curriedResult = ([func] as FunctionalArray<[string, number, string, number], string>).mapToCall("hello", 1);

In the above example, mapToCall was called with only the first two arguments, and returned a FunctionalArray of partially-applied functions - when called, the functions stored in curriedResult would call func with the first two arguments being fixed as 'hello' and 1.

There's A great StackOverflow answer on typing curried functions by SO user jcalz, so it seems possible to type this overload of mapToCall correctly, even if it does require manually unrolling type signatures for various numbers of function arguments. I think it goes a bit beyond the scope of this post, but I might write about it in the future.

Top comments (1)

Collapse
 
sly1024 profile image
Szilveszter Safar • Edited

Or you can do:

_ = new Proxy({}, { get: (t, prop) => (obj) => obj[prop] });
call = new Proxy({}, { get: (t, prop) => (obj) => obj[prop]() });

arr = [{ a: 1, b: 2}, { a: 3, b: 4}];
arr.map(_.a) // [1, 3]
arr.map(_.b) // [2, 4]
arr.map(call.toString) // ["[object Object]", "[object Object]"]
Enter fullscreen mode Exit fullscreen mode