Tackling TypeScript
Please support this book: buy it or donate
(Ad, please don’t block.)

19 Typing Arrays



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

19.1 Roles of Arrays

Arrays can play the following roles in JavaScript (either one or a mix of them):

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

19.2 Ways of typing Arrays

19.2.1 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[]:

// Each Array element has the type `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, we need parentheses for Array type literals:

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

The generic type Array works better in this case:

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

19.2.2 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];

19.2.3 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',
};

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

19.3.1 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: Fields = [
  ['first', 'string', true],
  ['last', 'string', true],
  ['age', 'number', false],
];

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

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

19.3.2 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: (string | number)[]
const arr = [123, 'abc'];

Alas, that’s not always what we want:

function func(p: [number, number]) {
  return p;
}
// %inferred-type: number[]
const pair1 = [1, 2];

// @ts-expect-error: Argument of type 'number[]' is not assignable to
// parameter of type '[number, number]'. [...]
func(pair1);

We can fix this by adding a type annotation to the const declaration, which avoids type inference:

const pair2: [number, number] = [1, 2];
func(pair2); // OK

19.3.3 Type inference for empty Array literals

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

// %inferred-type: any[]
const 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-expect-error: Argument of type '"abc"' is not assignable to
// parameter of type 'number'. (2345)
arr.push('abc');

19.3.4 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 rockCategories won’t change. That has the following effects:

Here are more examples of Array literals with and without const assertions:

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

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

There are two potential pitfalls with const assertions.

First, the inferred type is as narrow as possible. That causes an issue for let-declared variables: We cannot assign any tuple other than the one that we used for intialization:

let arr = [1, 2] as const;

arr = [1, 2]; // OK

// @ts-expect-error: Type '3' is not assignable to type '2'. (2322)
arr = [1, 3];

Second, tuples declared via as const can’t be mutated:

let arr = [1, 2] as const;

// @ts-expect-error: Cannot assign to '1' because it is a read-only
// property. (2540)
arr[1] = 3;

That is neither an upside nor a downside, but we need to be aware that it happens.

19.4 Pitfall: TypeScript assumes indices are never out of bounds

Whenever we access an Array element via an index, TypeScript always assumes that the index is within range (line A):

const messages: string[] = ['Hello'];

// %inferred-type: string
const message = messages[3]; // (A)

Due to this assumption, the type of message is string. And not undefined or undefined|string, as we may have expected.

We do get an error if we use a tuple type:

const messages: [string] = ['Hello'];

// @ts-expect-error: Tuple type '[string]' of length '1' has no element
// at index '1'. (2493)
const message = messages[1];

as const would have had the same effect because it leads to a tuple type being inferred.