HomepageExploring TypeScript (TS 5.8 Edition)
You can support this book: buy it or donate
(Ad, please don’t block.)

24 Typing Arrays

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

24.1 Ways of typing Arrays

TypeScript has two kinds of Array types:

24.1.1 Array types: Array type literal T[] vs. generic type Array<T>

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 equivalent to using the generic 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>

24.1.2 Tuple types: 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];

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

If an interface only has 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',
};

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

24.2.1 Inferring types of Arrays is difficult

Due to the two kinds of Array types, 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>,
];

24.2.2 Type inference for non-empty Array literals

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

const arr = [123, 'abc'];
assertType<(string | number)[]>(arr);

Alas, that’s not always what we want:

function func(p: readonly [number, number]) { // (A)
  return p;
}
const pair1 = [1, 2];
assertType<number[]>(pair1);

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

We need the keyword readonly in this line so that pair3 (see below) is accepted.

We can fix the error in line B via a type annotation:

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

Another option is a const assertion (as const):

const pair3 = [1, 2] as const;
func(pair3); // OK

More on const assertions soon.

24.2.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:

// @ts-expect-error: Variable 'arr' implicitly has type 'any[]' in some
// locations where its type cannot be determined.
const arr = []; // (A)
// @ts-expect-error: Variable 'arr' implicitly has an 'any[]' type.
assertType<any[]>(arr); // (B)

arr.push(123);
assertType<number[]>(arr);

arr.push('abc');
assertType<(string | number)[]>(arr);

Interestingly, the error in line A goes away if we remove line B: TypeScript only complains about any[] if that type is observed in some manner.

If we use assignment instead of .push(), things work the same:

// @ts-expect-error: Variable 'arr' implicitly has type 'any[]' in some
// locations where its type cannot be determined.
const arr = [];
// @ts-expect-error: Variable 'arr' implicitly has an 'any[]' type.
assertType<any[]>(arr);

arr[0] = 123;
assertType<number[]>(arr);

arr[1] = 'abc';
assertType<(string | number)[]>(arr);

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

const arr = [123];
assertType<number[]>(arr);

// @ts-expect-error: Argument of type 'string' is not assignable to
// parameter of type 'number'.
arr.push('abc');

24.3 Const assertions for Arrays and their effect on type inference

We can suffix an Array literal with a const assertion:

const rockCategories =
  ['igneous', 'metamorphic', 'sedimentary'] as const;
assertType<
  readonly ['igneous', 'metamorphic', 'sedimentary']
>(rockCategories);

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:

const numbers1 = [1, 2, 3, 4] as const;
assertType<readonly [1, 2, 3, 4]>(numbers1);
const numbers2 = [1, 2, 3, 4];
assertType<number[]>(numbers2);

const booleanAndString1 = [true, 'abc'] as const;
assertType<readonly [true, 'abc']>(booleanAndString1);
const booleanAndString2 = [true, 'abc'];
assertType<(string | boolean)[]>(booleanAndString2);

24.3.1 as const pitfall: A const tuple is not assignable to a mutable type

function argIsArray(arg: Array<string>) {}
function argIsReadonlyArray(arg: ReadonlyArray<string>) {}
function argIsTuple(arg: [string, string]) {}
function argIsReadonlyTuple(arg: readonly [string, string]) {}

const constTuple = ['a', 'b'] as const;
// @ts-expect-error: Argument of type 'readonly ["a", "b"]' is not
// assignable to parameter of type '[string, string]'. The type 'readonly
// ["a", "b"]' is 'readonly' and cannot be assigned to the mutable type
// '[string, string]'.
argIsTuple(constTuple);
argIsReadonlyTuple(constTuple);
// @ts-expect-error: Argument of type 'readonly ["a", "b"]' is not
// assignable to parameter of type 'string[]'. The type 'readonly
// ["a", "b"]' is 'readonly' and cannot be assigned to the mutable type
// 'string[]'.
argIsArray(constTuple);
argIsReadonlyArray(constTuple);

A mutable tuple does not have this issue:

const mutableTuple: [string, string] = ['a', 'b'];
argIsTuple(mutableTuple);
argIsReadonlyTuple(mutableTuple);
argIsArray(mutableTuple);
argIsReadonlyArray(mutableTuple);

Lesson for us: If possible, we should use readonly for tuple parameters of functions and methods.

24.3.2 as const pitfall: The inferred type is very narrow

The inferred type of a const-asserted Array literal 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'.
arr = [1, 3];

24.3.3 More information on read-only Arrays and tuples

See “Read-only accessibility (readonly etc.)” (§25).

24.4 Array pitfall: checking if an element exists

With the compiler option noUncheckedIndexedAccess, TypeScript always infers the type undefined | T for elements of an Array<T>:

function f(arr: Array<string>): void {
  if (arr.length > 0) { // (A)
    const elem = arr[0];
    assertType<string | undefined>(elem);
  }
}

Even checking .length in line A doesn’t change that. However, the in operator works better:

function f(arr: Array<string>): void {
  if (0 in arr) {
    const elem = arr[0];
    assertType<string>(elem);
  }
}

Alternatively, we can check for undefined after accessing the Array element:

function f(arr: Array<string>): void {
  const elem = arr[0];
  if (elem !== undefined) {
    assertType<string>(elem);
  }
}

24.5 Why are there two notations? Why does TypeScript prefer T[]?

The following two notations for an Array of strings are completely equivalent:

Why are there two notations?

So it looks like TypeScript simply stuck with the status quo when version 0.9 came along: It still exclusively uses T[] – e.g., when inferring types or even when displaying a type that was written Array<T>.

Even though I have read once or twice that JSX is the cause of TypeScript’s preference, I don’t think that’s the case:

24.5.1 Why I prefer the notation Array<T>

I find it often looks better – especially if constructs related to element types get more complicated:

// Array of tuples
type AT1 = Array<[string, number]>;
type AT2 = [string, number][];

// Array of union elements
type AU1 = Array<number | string>;
type AU2 = (number | string)[];

// Inferring type variables
type ExtractElem1<A> = A extends Array<infer Elem> ? Elem : never;
type ExtractElem2<A> = A extends (infer Elem)[] ? Elem : never;

// Readonly types
type RO1 = ReadonlyArray<unknown>;
type RO2 = readonly unknown[];
  // `readonly` applies to `[]` not to `unknown`!

More reasons:

24.5.2 One point in favor of T[]

Because TypeScript always uses T[], code that uses that notation is more consistent with language tooling.

24.5.3 Syntactic caveat: The [] in T[] binds strongly

The square brackets in T[] bind strongly. Therefore, we need to parenthesize any type T that consists of more than a single token – e.g. (line A, line B):

type ArrayOfStringOrNumber = (string | number)[]; // (A)

const Activation = {
  Active: 'Active',
  Inactive: 'Inactive',
} as const;
type ActivationKeys = (keyof typeof Activation)[]; // (B)
  // ("Active" | "Inactive")[]

24.5.4 Linting Array notations

typescript-eslint has the rule array-type for enforcing a consistent Array notation style. The options are: