Typing Arrays in TypeScript

[2020-02-24] dev, javascript, typescript
(Ad, please don’t block)

In this blog post, we examine how Arrays can be typed in TypeScript.

Roles of Arrays  

Arrays are used in the following two roles in JavaScript (and sometimes a mix of the two):

  • Lists: All elements have the same type. The length of the Array varies.
  • Tuple: The length of the Array is fixed. The elements do not necessarily have the same type.

TypeScript accommodates these two roles by offering various ways of typing arrays. We will look at those next.

Ways of typing Arrays  

Array role “list”: array type literals vs. interface type Array  

An Array type literal consists of the element type followed by []. In the following code, the Array type literal is string[]:

const myStringArray: string[] = ['fee', 'fi', 'fo', 'fum'];

An Array type literal is a shorthand for using the global generic interface type Array:

const myStringArray: Array<string> = ['fee', 'fi', 'fo', 'fum'];

If the element type is more complicated, then you need to put them in parentheses:

(number|string)[]
(() => boolean)[]

In this case, I prefer Array:

Array<number|string>
Array<() => boolean>

Both array type literals and Array require all elements to have the same type. That’s how we know that they are for Arrays-as-lists.

Array role “tuple”: tuple type literals  

If the Array has a fixed length and each element has a different, fixed type that depends on its position, then we can use tuple type literals such as [string, string, boolean]:

const yes: [string, string, boolean] = ['oui', 'sí', true];

Objects that are also Array-ish: interfaces with index signatures  

If an interface has only an index signature, we can use it for Arrays:

interface StringArray {
  [index: number]: string;
}
const strArr: StringArray = ['Huey', 'Dewey', 'Louie'];

An interface that has both an index signature and property signatures, only works for objects (because indexed elements and properties need to be defined at the same time):

interface FirstNamesAndLastName {
  [index: number]: string;
  lastName: string;
}

const ducks: FirstNamesAndLastName = {
  0: 'Huey',
  1: 'Dewey',
  2: 'Louie',
  lastName: 'Duck',
};

Pitfall: type inference doesn’t always get Array types right  

Inferring types of Arrays is difficult  

Due to the two roles of Arrays, it is impossible for TypeScript to always guess the right type. As an example, consider the following Array literal that is assigned to the variable fields:

const fields = [
  ['first', 'string', true],
  ['last', 'string', true],
  ['age', 'number', false],
];

What is the best type for fields? The following are all reasonable options:

type Fields1 = Array<[string, string, boolean]>;
type Fields2 = Array<[string, ('string'|'number'), boolean]>;
type Fields3 = Array<Array<string|boolean>>

type Fields4 = [
  [string, string, boolean],
  [string, string, boolean],
  [string, string, boolean],
];
type Fields5 = [
  [string, 'string', boolean],
  [string, 'string', boolean],
  [string, 'number', boolean],
];
type Fields6 = [
  Array<string|boolean>,
  Array<string|boolean>,
  Array<string|boolean>,
];

// Etc.

Type inference for non-empty Array literals  

When we use non-empty Array literals, TypeScript’s default is to infer list types (not tuple types):

// %inferred-type: number[]
const arr = [123];

Alas, that’s not always what we want:

type Pair = [string, boolean];

// %inferred-type: (str: string) => (string | boolean)[]
const callback = (str: string) => [str, Boolean(str)];

// @ts-ignore: Type '(string | boolean)[][]' is not assignable to
// type 'Pair[]'.
//   Type '(string | boolean)[]' is missing the following properties
//   from type 'Pair': 0, 1 (2322)
const pairs: Array<Pair> = ['hello', '', '!!!'].map(callback);

The inferred type for callback should be a tuple type. It isn’t and that’s why its results don’t match the type parameter Pair of Array<> in the last line.

We can fix this by specifying the return type of callback explicitly (instead of relying on type inference):

const callback2 = (str: string): [string, boolean] =>
  [str, Boolean(str)];
const pairs2: Array<Pair> = ['hello', '', '!!!'].map(callback2);

We also could have used Pair instead of [string, boolean] for the result of callback2.

Type inference for empty Array literals  

If we initialize a variable via an empty Array literal, then TypeScript initially infers the type any[] and incrementally updates that type as we make changes:

const arr1 = [];
// %inferred-type: any[]
arr1;

arr1.push(123);
// %inferred-type: number[]
arr1;

arr1.push('abc');
// %inferred-type: (string | number)[]
arr1;

Note that the initial inferred type isn’t influenced by what happens later. If we use assignment instead of .push(), things work the same:

// %inferred-type: any[]
const arr1 = [];

arr1[0] = 123;
// %inferred-type: number[]
arr1;

arr1[1] = 'abc';
// %inferred-type: (string | number)[]
arr1;

In contrast, if the Array literal has at least one element, then the element type is fixed and doesn’t change later:

// %inferred-type: number[]
const arr = [123];

// @ts-ignore: Argument of type '"abc"' is not assignable to
// parameter of type 'number'. (2345)
arr.push('abc');

const assertions for Arrays and type inference  

We can suffix an Array literal with a const assertion:

// %inferred-type: readonly ["igneous", "metamorphic", "sedimentary"]
const rockCategories = ['igneous', 'metamorphic', 'sedimentary'] as const;

We are declaring that categoriesOfRocks won’t change – which has the following effects:

  • The Array becomes readonly: We can’t use operations that change it:

    // @ts-ignore: Property 'push' does not exist on type
    // 'readonly ["igneous", "metamorphic", "sedimentary"]'. (2339)
    rockCategories.push('sand');
    
  • TypeScript infers a tuple. Compare:

    // %inferred-type: string[]
    const rockCategories2 = ['igneous', 'metamorphic', 'sedimentary'];
    
  • TypeScript infers literal types ("igneous" etc.) instead of more general types. That is, the inferred tuple type is not [string, string, string].

Here are two more examples of const assertions:

// %inferred-type: number[]
const numbers1 = [1, 2, 3, 4];
// %inferred-type: readonly [1, 2, 3, 4]
const numbers2 = [1, 2, 3, 4] as const;

// %inferred-type: (string | boolean)[]
const booleanAndString1 = [true, 'abc'];
// %inferred-type: readonly [true, "abc"]
const booleanAndString2 = [true, 'abc'] as const;