'TypeScript function that works on all numerical array types

I am trying to write a function that works on all of the JavaScript array types, e.g. on number[], Float32Array etc. It should return the same type that it gets as a parameter. A simple example would be:

function addOne<T>(a: T) : T {
  return a.map((x: number) => x + 1);
}

The function should be able to use all methods common to all array types (not just map).

I also tried

type NumberArray<T> = T extends Array<number>
  ? Array<number>
  : Float32Array; // other array types skipped for brevity


function addOne<T>(a: NumberArray<T>): NumberArray<T> {
  return a.map((x: number) => x + 1);
}

but I get

TS2322: Type 'number[] | Float32Array' is not assignable to type 'NumberArray<T>'.   Type 'number[]' is not assignable to type 'NumberArray<T>'.

What would the TypeScript signature of such a function be? I also want to be able to create several such function and pass them as a parameter to another function (all properly typed, of course). A trivial example would be:

function doSomethingWithArray(a, func) {
  return func(a);
}

The type of a should define which signature of func is used. I have no problems running this in JS, but when trying to add proper TS typing, the TS compiler complains (I am running with "strict": true compiler option).



Solution 1:[1]

TypeScript does not have a built-in NumericArray type of which Array<number> and Float32Array-et-cetera are subtypes that gives you access to all common methods. Nor can I think of a one-or-two line solution that will give that to you. Instead, if you really need this, I'd suggest you create your own type. For example:

interface NumericArray {
  every(predicate: (value: number, index: number, array: this) => unknown, thisArg?: any): boolean;
  fill(value: number, start?: number, end?: number): this;
  filter(predicate: (value: number, index: number, array: this) => any, thisArg?: any): this;
  find(predicate: (value: number, index: number, obj: this) => boolean, thisArg?: any): number | undefined;
  findIndex(predicate: (value: number, index: number, obj: this) => boolean, thisArg?: any): number;
  forEach(callbackfn: (value: number, index: number, array: this) => void, thisArg?: any): void;
  indexOf(searchElement: number, fromIndex?: number): number;
  join(separator?: string): string;
  lastIndexOf(searchElement: number, fromIndex?: number): number;
  readonly length: number;
  map(callbackfn: (value: number, index: number, array: this) => number, thisArg?: any): this;
  reduce(callbackfn: (previousValue: number, currentValue: number, currentIndex: number, array: this) => number): number;
  reduce(callbackfn: (previousValue: number, currentValue: number, currentIndex: number, array: this) => number, initialValue: number): number;
  reduce<U>(callbackfn: (previousValue: U, currentValue: number, currentIndex: number, array: this) => U, initialValue: U): U;
  reduceRight(callbackfn: (previousValue: number, currentValue: number, currentIndex: number, array: this) => number): number;
  reduceRight(callbackfn: (previousValue: number, currentValue: number, currentIndex: number, array: this) => number, initialValue: number): number;
  reduceRight<U>(callbackfn: (previousValue: U, currentValue: number, currentIndex: number, array: this) => U, initialValue: U): U;
  reverse(): this;
  slice(start?: number, end?: number): this;
  some(predicate: (value: number, index: number, array: this) => unknown, thisArg?: any): boolean;
  sort(compareFn?: (a: number, b: number) => number): this;
  toLocaleString(): string;
  toString(): string;
  [index: number]: number;
}

That's kind of long, but I created it by merging the existing array typings in lib.es5.d.ts, and changing any reference to the particular array type with the polymorphic this type, meaning "the current subtype of the NumericArray interface". So, for example, Array<number>'s map() method returns an Array<number>, while a Float32Array's map() method returns a Float32Array. The this type can be used to represent this relationship between the array type and the return type.

If you care about post-ES5 functionality you can go and merge those methods in also, but this should be enough to demonstrate the basic approach.

You could try to write something that computes NumericArray programmatically, but I wouldn't want to. It is likely to be more fragile and more confusing than the manual NumericArray definition above, and probably take nearly as many lines.


Then, I'd write your addOne() in terms of NumericArray:

function addOne<T extends NumericArray>(a: T): T {
  return a.map((x: number) => x + 1);
}

And you can verify that it works as expected for Array<number> and Float32Array:

const arr = addOne([1, 2, 3]);
// const arr: number[]
console.log(arr); // [2, 3, 4];
arr.unshift(1); // okay

const float32Arr = addOne(new Float32Array([1, 2, 3]));
// const float32Arr: this
console.log(float32Arr) // this: {0: 2, 1: 3, 2: 4}
console.log(float32Arr.buffer.byteLength); // 12

And your doSomethingWithArray would look like this:

function doSomethingWithArray<T extends NumericArray, R>(a: T, func: (a: T) => R) {
  return func(a);
}
console.log(doSomethingWithArray([4, 5, 6], x => x.unshift(3))); // 4
console.log(doSomethingWithArray(new Int8Array([1, 2, 3, 4, 5]), x => x.byteLength)); // 5

Looks good!

Playground link to code

Solution 2:[2]

I was reminded of this question today and came up with something I find interesting:

function addOne <T extends NumericArray[number]> (a: T)
    : Narrow<T, NumericArray> {
        return a.map(x => x + 1) }

type NumericArray = [
    Array<number>,
    Int8Array,
    Uint8Array,
    Uint8ClampedArray,
    Int16Array,
    Uint16Array,
    Int32Array,
    Uint32Array,
    Float32Array,
    Float64Array
];

type Narrow<
    T, L extends unknown[] = [],
    R1 extends unknown[] = {
        [K in keyof L]: T extends L[K] ? { type: T } : never
    },
    R2 = unknown extends T ? never : R1[number],
> = [R2] extends [{ type: unknown }] ? R2['type'] : never

I like how it has a different maintainability profile than jcalz's answer, which I still believe made more sense for your use case because the API is cleaner and you had to write a lot of functions for the same set of variants. That was a lot of grunt work though and I would see myself reaching for this second solution if I had to do this for different sets of variants.

I guess the API could be a little cleaner if Narrow took a union instead of a tuple but I don't know how computationally intensive that would be to add this step.

Solution 3:[3]

Your last question has a simple solution

function apply<T, U>(a: T, func: (x: T) => U): U {
    return func(a);
}

UPDATE

This seems to work and I fail to see what is unsafe about it.

type NumberArray = 
    { map:(f:(x:number) => number) => any } 
    & ArrayLike<number> 
    & Iterable<number>;


function addOne<T extends NumberArray>(a: T):T {
    return a.map((x: number) => x + 1)
}

I don't think it's possible to do what you want with number[] and Float32Array because there is no such thing as a Float32 type in Javascript and we can't express something like "I want a generic Wrapper around a type Number | Float32 | ..." in Typescript anyway. Without these 2 features I don't see how it could be done.

We could do something like that which makes Typescript happy:

type NumberArray =
    {
        map: Function
        // define other methods there
    }
    & ArrayLike<number> 
    & Iterable<number>;


function addOne<T extends NumberArray>(a: T): T {
    return a.map((x: number) => x + 1)
}

But it allows you to map to a type other than number:

function addOne<T extends NumberArray>(a: T): T {
    return a.map((x: number):string => x + '!') // woops
}

We could do the following:

type NumberArray =
    {
        map:(f:(x:number) => number) => NumberArray
        // define other methods there
    }
    & ArrayLike<number> 
    & Iterable<number>;


function addOne<T extends NumberArray>(a: T): T {
    return a.map((x: number) => x + 1)
}

but then we would get an error.

Type 'NumberArray' is not assignable to type 'T'.
  'NumberArray' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint '{ map: (f: (x: number) => number) => NumberArray; } & ArrayLike<number> & Iterable<number>

it can only be used like so, which looses type information (maybe it's OK for you though)

function addOne(a: NumberArray) {
    return a.map((x: number):number => x + 1)
}

On a side note, I don't see the value of creating an addOne function which can only work with array-like objects. That's very specific. The whole point of map is to decouple the data structure from the transformation.

The following is more flexible because addOne doesn't need to know about where it's going to be used and the type information is preserved:

const addOne = (x: number) => x + 1;

addOne(3);
[1, 2, 3].map(addOne);
Float32Array.from([1,2,3]).map(addOne);

It doesn't solve your problem though: as soon as we define a function which needs to accept a NumberArray as argument we are back where we started...

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2 geoffrey
Solution 3