Type-defining a function that takes an array as an argument and returns an array by TypeScript

Posted on: 3/24/2021

Overview

In this article, I will show how to type a function whose arguments and return values are arrays by TypeScript. This content is based on the following article I wrote on Qiita the other day, but since that article was written with the minimum necessary content based on the final result, I'd like to start with some more elementary content and write about various things here.

Type-defining a function that takes an array as an argument and returns an array in TypeScript

When array elements are of primitive type

First of all, let's consider a function that takes an array of type string[] as an argument and returns an array of type string[]. This function can be typed as follows.

In this article, the array of arguments is named beforeList, the array of return values is named afterList, and the name of the function is convert.

function convert(beforeList: string[]): string[];

This can be written if you know array notation, so it should be no problem. Next, let's consider the case where the elements of an array can be of multiple types.

When multiple primitives appear as elements of an array

Considering the support for multiple types, you need to work out a little more details about what type you want to define. For example, if you think "the elements of the array of arguments can be of type string or number", the following two cases are possible.

  • When string and number types are mixed in array elements
  • If you have an array whose elements are either string or number, but you want to support both cases with a single type definition

When array elements of type string and number are mixed together

This can also be written with elementary knowledge.

function convert(beforeList: Array<string | number>): Array<string | number>;

const afterlist = convert([1, 'a', 2, 'b']); // AfterList will be of type Array<string | number

Since we only use the Union type, the level of detail is the same as in the first example.

If the elements of an array are of either string or number type, but you want to support both cases with a single type definition

In this case, the previous type definition is fine, but a better way is to use generics to define the type. The difference between the previous example and this one is that in this case, we want to narrow down the type of the element to either string or number, while the previous example leaves the ambiguity of choosing either string or number for the array element. To do this, we need to infer the type from the arguments. For this purpose, we will take the type of the argument as the type variable T.

function convert<T>(beforeList: T): T; // The return value should be the same type as the argument, T.

In the above example, the argument of the function which was defined as string | number is received as a type variable T. To show that T is a type variable, the declaration <T> is added before the function argument (). Because the type of the argument is received in the variable T, the information can be used for the type of the return value.

Now the function convert can take any type as an argument, and it will be the type of the return value. However, what we initially wanted to do was to take an array of type string or number and return an array of the same type as the arguments. If the argument can be any type, it is too loose a type. So, to constrain the type argument T, we write it as follows.

function convert<T extends string[] | number[]>(beforeList: T): T;

Now, the type argument T that we just declared must satisfy the constraint extends string[] | number[]. What this extends means is that the constraint of the argument is whether T is a subtype of string[] | number[], in other words, whether T can be assigned to string[] | number[]. Now, we assume that the arguments are of type string[] or number[], so they satisfy the condition that they are subtypes of string[] | number[]. On the other hand, if, for example, T contains any or string, they are not subtypes of string[] | number[], so the constraint is violated and a type error occurs. Therefore, this is the constraint we are aiming for.

As long as you're using a complete library like React, you're unlikely to need this level of type definition, so I think this will be the first hurdle for TypeScript type puzzles. As I did, it's hard to learn type definitions when you're studying them for their own sake, but when you need them as a means to an end, you can learn them surprisingly quickly.

Development of the case where array elements of type string and number are mixed up

In this section, we will consider how to add a type to the return value while maintaining the type information of each element, as an extension of the case where the string and number types are mixed up. In order to achieve this, we need to use the Tuple type instead of the array type. Therefore, the function we are going to define cannot pass a dynamically generated array as an argument at program execution time. However, if the number of elements in the array is fixed at the time of coding, it can be passed as an argument by defining it with the Tuple type.

The TypeScript tuple type can be thought of as an array type in which the number of elements is fixed and the type of each element is defined.

Such types can be written as follows using a TypeScript feature called Variadic Tuple Types.

function convert<T extends ReadonlyArray<string | number>>(beforeList: [...T]): [
    ...{ [K in keyof T]: T[K] extends string ? string : number}
];

const afterList = convert([1, 'a', 1]) // afterList will be of type [number, string, number].

In the above, [1, 'a', 1] is written in the argument of convert, so it is judged to be a tuple type. The point of Variadic Tuple Types is that the argument can be received in the type [...T]. That is, the argument is assumed to be an expansion of some Tuple T, and the whole element of the argument is received in T. After receiving the argument, each element of T can be indexed, and the type of the return value can be determined by checking whether it is a subtype of stirng with extends, as shown in the example.

If you want to define the tuple of arguments outside the function, write as follows.

const beforeList: [number, string, number] = [1, 'a', 1];
const afterList = convert(beforeList);

When the array elements are of generic type

By applying the above example a little more, we can take a Tuple of a generic type as an argument and type it into a function that returns a Tuple of another generic type while keeping the type variable inherited. I have written about this in the following article, which I also posted at the beginning of this article, so I will just write the result.

// Define the Before<T> and After<T> types as samples
type Before<T> = {
    type: 'before',
    value: T,
}

type After<T> = {
    type: 'after',
    value: T,
}

// Type definition of convert (implementation of the function body is omitted)
function convert<T extends ReadonlyArray<Before<unknown>>>(beforeList: [...T]): [
    ...{ [K in keyof T]: T[K] extends Before<infer U> ? After<U> : never }
]

// Test
const before1: Before<number> = {
    type: 'before',
    value: 1,
}

const before2: Before<string> = {
    type: 'before',
    value: 'string',
}

const afterList = convert([before1, before2]); // This is of type [After<number>, After<string>].

Although there are restrictions on what can be defined with the Tuple type, some JS libraries provide functions that create an array of other objects from an array of objects, so I think this method may be useful in situations where you need to define types for such functions.