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)
Or you can do: