In this chapter, we examine how Arrays can be typed in TypeScript.
TypeScript has two kinds of Array types:
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>
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];
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',
};
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>,
];
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.
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');
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:
The Array becomes readonly
– we can’t use operations that change it:
// @ts-expect-error: Property 'push' does not exist on type
// 'readonly ["igneous", "metamorphic", "sedimentary"]'.
rockCategories.push('sand');
TypeScript infers a tuple. Compare:
const rockCategories2 = ['igneous', 'metamorphic', 'sedimentary'];
assertType<string[]>(rockCategories2);
TypeScript infers narrow literal types ('igneous'
etc.) instead of more general types. That is, the inferred tuple type is not [string, string, string]
.
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);
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.
as const
pitfall: The inferred type is very narrowThe 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];
See “Read-only accessibility (readonly
etc.)” (§25).
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);
}
}
T[]
?The following two notations for an Array of strings are completely equivalent:
string[]
Array<string>
Why are there two notations?
Array<T>
.
T[]
was the only notation for Arrays.
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:
The Array<T>
notation is compatible with JSX: It only exists at the type level and can be used without any problems in .jsx
files.
JSX does, however, make one syntax impossible to use – type assertions via angle brackets:
// Not possible in .tsx files:
const value1 = <DesiredType>valueWithWrongType;
// Can be used everywhere:
const value2 = valueWithWrongType as DesiredType;
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:
Array<T>
looks similar to Set<T>
and Map<K,V>
.
T[]
can be confused with [T]
(tuples that have a single component whose type is T
) – especially by people new to TypeScript.
If we want to create an empty Array without a type annotation then that syntax is more consistent with the angle bracket type notation:
const strArr = new Array<string>();
T[]
Because TypeScript always uses T[]
, code that uses that notation is more consistent with language tooling.
[]
in T[]
binds stronglyThe 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")[]
typescript-eslint has the rule array-type
for enforcing a consistent Array notation style. The options are:
T[]
Array<T>
T[]
for single-token T
; otherwise Array<T>