Arrays
An array is an object whose keys happen to be numbers, with a length that maintains itself. That is not a metaphor: indices really are property keys, length really is a property, and almost every surprising thing an array does follows from those two sentences. The convenient parts you already have; this entry is the rest.
In TypeScript there is one decision to make up front, and the language has two shapes for it. T[] for a sequence: any number of elements, all the same type, like a list, a stack, or the lines of a file. [A, B] for a fixed layout: a known number of positions, each with its own type, like a pair or a key and its value. JavaScript makes no distinction between the two and uses arrays for both, so the choice is now yours to make explicitly, and making it wrong is a real cost.
Create programs/arrays.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:
import {
assert,
assertEquals,
assertFalse,
assertStrictEquals,
assertThrows,
} from "@std/assert";
Follow the page as you add and revise the runnable examples below that import.
an array is an object with a self-maintaining length
Deno.test("an array is an object with a self-maintaining length", () => {
const fruit = ["apple", "banana", "cherry"];
assertStrictEquals(fruit[0], "apple");
assertStrictEquals(fruit.at(-1), "cherry");
assertStrictEquals(fruit.length, 3);
fruit[1] = "blueberry";
fruit.push("date");
assertEquals(fruit, ["apple", "blueberry", "cherry", "date"]);
assert(Array.isArray(fruit));
});
Check programs/arrays.test.ts
running 1 test from ./programs/arrays.test.ts
an array is an object with a self-maintaining length ... ok (298µs)
ok | 1 passed | 0 failed (2ms)
A literal in square brackets, an index in square brackets to read or write, .at() for counting from the end, .length for how many, .push() to append, Array.isArray() to ask. A trailing comma after the last element is allowed and ignored, which is why arrays diff well in version control. For getting the elements out again, the loops page covers for-of over elements, over indices, and over both at once.
indices are property keys
Deno.test("indices are property keys", () => {
const withExtra = Object.assign(["a", "b"], { prop: 123 });
assertEquals(Object.keys(withExtra), ["0", "1", "prop"]);
assertStrictEquals(withExtra["0"], "a");
assertStrictEquals(withExtra[0], "a");
assertEquals(withExtra.keys().toArray(), [0, 1]);
assertEquals(withExtra.entries().toArray(), [[0, "a"], [1, "b"]]);
assertEquals(Object.entries(withExtra), [
["0", "a"],
["1", "b"],
["prop", 123],
]);
});
indices are property keys ... ok (88µs)
An array can carry an ordinary property, and Object.keys lists it beside the indices. Note that the indices come back as strings, "0" and "1": the bracket operator coerces its argument to a string, so withExtra["0"] and withExtra[0] are the same lookup, and TypeScript accepts both. So there are two families of listing operation with different ideas of what an array contains. Object.keys and Object.entries see an object: string keys, including the ones that are not indices. .keys() and .entries() see an array: numbers, and nothing that is not an element, collected here with the iterator helpers page's .toArray(). Neither is wrong, and you want the second nearly always. Key order is the rule the objects as dictionaries page measured in only canonical array indices are ordered first, and this is the case that rule was written for.
a negative index makes a property
Coming from a language with negative indexing, predict what the write does:
Deno.test("a negative index makes a property", () => {
const letters = ["a", "b", "c"];
letters[-1] = "not an element";
assertEquals(Array.from(letters), ["a", "b", "not an element"]);
});
Check programs/arrays.test.ts
running 3 tests from ./programs/arrays.test.ts
...
a negative index makes a property ... FAILED (9ms)
ERRORS
a negative index makes a property => ./programs/arrays.test.ts:39:6
error: AssertionError: Values are not equal.
[Diff] Actual / Expected
[
"a",
"b",
- "c",
+ "not an element",
]
FAILURES
a negative index makes a property => ./programs/arrays.test.ts:39:6
FAILED | 2 passed | 1 failed (10ms)
error: Test failed
The elements are untouched. -1 is a perfectly good property key and not an index, so the assignment created a property, left the elements alone, and nothing objected: length did not move, every loop over the array ignores it, and TypeScript saw an ordinary element write, because an array type has a number index signature and -1 is a number. This is a case where a reader arriving with negative-indexing habits gets no help from anyone. Correct the test to measure the whole shape:
Deno.test("a negative index makes a property", () => {
const letters = ["a", "b", "c"];
letters[-1] = "not an element";
assertStrictEquals(letters.length, 3);
assertEquals(Array.from(letters), ["a", "b", "c"]);
assertStrictEquals(letters[-1], "not an element");
assertStrictEquals(letters.at(-1), "c");
});
a negative index makes a property ... ok (30µs)
.at() is the operation that means what you intended, and it exists because the bracket operator could not be fixed without breaking code that relies on this behaviour.
the checker is optimistic about brackets
Two ways to read element zero of the same array:
Deno.test("the checker is optimistic about brackets", () => {
const names = ["Ada", "Grace"];
function shout(text: string): string {
return text.toUpperCase();
}
assertStrictEquals(shout(names[0]), "ADA");
assertStrictEquals(shout(names.at(0)), "ADA");
});
Check programs/arrays.test.ts
TS2345 [ERROR]: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
Type 'undefined' is not assignable to type 'string'.
assertStrictEquals(shout(names.at(0)), "ADA");
~~~~~~~~~~~
at file:///programs/arrays.test.ts:58:28
error: Type checking failed.
The checker has different opinions about them: names[0] is string, and names.at(0) is string | undefined. The second is telling the truth, because an index can be out of range, and reading past the end gives undefined at run time whichever syntax you used. The first is a deliberate convenience, and noUncheckedIndexedAccess is the option that withdraws it, which the objects as dictionaries page flipped for the dictionary case in an index signature promises more than it can keep; this is the array-shaped half of the same fact. Worth knowing rather than worth fixing: use .at() where the index is genuinely uncertain, and narrow what it returns:
Deno.test("the checker is optimistic about brackets", () => {
const names = ["Ada", "Grace"];
function shout(text: string): string {
return text.toUpperCase();
}
assertStrictEquals(shout(names[0]), "ADA");
assertStrictEquals(names.at(9), undefined);
const last = names.at(-1);
assert(last !== undefined);
assertStrictEquals(shout(last), "GRACE");
});
the checker is optimistic about brackets ... ok (31µs)
length is a property, and writing it deletes
Deno.test("length is a property, and writing it deletes", () => {
const trimmed = ["a", "b", "c"];
trimmed.length = 1;
assertEquals(trimmed, ["a"]);
trimmed[trimmed.length] = "b";
assertEquals(trimmed, ["a", "b"]);
const shared = ["a", "b"];
const alsoShared = shared;
shared.length = 0;
assertEquals(alsoShared, []);
});
length is a property, and writing it deletes ... ok (43µs)
length is always the highest index plus one, and it is writable. Assigning a smaller number throws elements away, and assigning at the current length appends one, which is what push does with a better name. The third case is the reason to know any of this: arr.length = 0 empties the array itself, so every name pointing at it sees an empty array, while arr = [] rebinds one name and leaves the array alone, the split from the values and references page. One of those is what you want, and it is nearly always the second.
arrays can have holes
Deno.test("arrays can have holes", () => {
const skipped: string[] = [];
skipped[0] = "a";
skipped[2] = "c";
assertEquals(Object.keys(skipped), ["0", "2"]);
assertStrictEquals(skipped.length, 3);
const elided = ["a", , "c"];
assertEquals(Object.keys(elided), ["0", "2"]);
const grown = ["a"];
grown.length = 3;
grown.push("d");
assertEquals(Object.keys(grown), ["0", "3"]);
const deleted = ["a", "b", "c"];
delete deleted[1];
assertEquals(Object.keys(deleted), ["0", "2"]);
const holed = ["a", , "c"];
assertFalse(1 in holed);
assertFalse(Object.hasOwn(holed, 1));
assertStrictEquals(holed[1], undefined);
});
arrays can have holes ... ok (51µs)
Because an array is a set of properties, an index in the middle can simply not exist. Four ways to arrive there: skip one when assigning, leave a gap in a literal, grow length, or delete. An array with a gap is sparse; one without is dense. The last three assertions pin what a hole is: not a slot holding undefined but an absent property, invisible to in and Object.hasOwn, that reads as undefined anyway, because reading any absent property does. That is the missing-versus-present-but-empty distinction from the nothing, twice page, and here it has teeth, because the operations disagree about which one a hole is.
TypeScript's one nod to holes points the wrong way: ["a", , "c"] is inferred as (string | undefined)[], which says the slot holds undefined when in fact there is no slot. The type is useful anyway, because undefined is what you will read, and the honesty stops there; every other way of making a hole produces a type that denies holes exist.
no two operations agree about holes
Deno.test("no two operations agree about holes", () => {
const holed = ["a", , "b"];
assertEquals(holed.filter(() => true), ["a", "b"]);
assertEquals(holed.map((value) => value), ["a", , "b"]);
assertEquals(Object.keys(holed.map((value) => value)), ["0", "2"]);
let visited = 0;
holed.forEach(() => visited++);
assertStrictEquals(visited, 2);
assertEquals(Array.from(holed), ["a", undefined, "b"]);
assertEquals(holed.entries().toArray(), [[0, "a"], [1, undefined], [2, "b"]]);
assert(holed.includes(undefined));
assertStrictEquals(holed.indexOf(undefined), -1);
assertEquals(holed.keys().toArray(), [0, 1, 2]);
assertEquals(Object.keys(holed), ["0", "2"]);
assertStrictEquals(holed.join("-"), "a--b");
assertEquals(holed.toSorted(), ["a", "b", undefined]);
});
no two operations agree about holes ... ok (80µs)
Nine behaviours and no rule connecting them. filter removes holes, map preserves them without calling the callback, forEach skips them, Array.from and entries and includes treat them as undefined, indexOf cannot see them, keys lists them while Object.keys does not, join renders them as nothing, and sorting moves them past the end. There is nothing to memorise here, and trying is a poor use of your memory. The practical rules are two. Do not create holes: push, or build with Array.from, or fill a length, and everything above is a consequence you do not need. And when it matters anyway, write a test, because four lines in a Deno.test answers the question for certain and stays answered, which is the discipline the understanding testing page builds.
Deno's linter has a rule for the literal form, and it is not in the recommended set, so it fires only when asked. Run deno lint --rules-include=no-sparse-arrays on this file:
error[no-sparse-arrays]: Sparse arrays are not allowed
--> programs/arrays.test.ts:86:18
|
86 | const elided = ["a", , "c"];
| ^^^^^^^^^^^^
docs: https://docs.deno.com/lint/rules/no-sparse-arrays
And seven more findings, one for every sparse literal this file deliberately contains, including the ones inside expected values. Like the equality page's museum of ==, this file is a catalogue of the thing the rule exists to prevent.
a tuple can grow, and you may not read what grew
A tuple type fixes the length and gives each position its own type. Push at it and predict the length:
Deno.test("a tuple can grow, and you may not read what grew", () => {
const pair: [string, number] = ["Ada", 36];
pair.push("extra");
assertStrictEquals(pair.length, 2);
});
Check programs/arrays.test.ts
running 8 tests from ./programs/arrays.test.ts
...
a tuple can grow, and you may not read what grew ... FAILED (8ms)
ERRORS
a tuple can grow, and you may not read what grew => ./programs/arrays.test.ts:126:6
error: AssertionError: Values are not strictly equal.
[Diff] Actual / Expected
- 3
+ 2
FAILURES
a tuple can grow, and you may not read what grew => ./programs/arrays.test.ts:126:6
FAILED | 7 passed | 1 failed (10ms)
error: Test failed
There is a third element, because pair.push("extra") was accepted: a tuple inherits every array method, and push takes the union of the element types. Now try to read it back:
Deno.test("a tuple can grow, and you may not read what grew", () => {
const pair: [string, number] = ["Ada", 36];
pair.push("extra");
assertStrictEquals(pair.length, 3);
assertStrictEquals(pair[2], "extra");
});
Check programs/arrays.test.ts
TS2493 [ERROR]: Tuple type '[string, number]' of length '2' has no element at index '2'.
assertStrictEquals(pair[2], "extra");
^
at file:///programs/arrays.test.ts:132:27
error: Type checking failed.
Reading past the end is a compile error naming the length, so you may append to a tuple and you may never read what you appended. readonly closes the gap, and does more:
Deno.test("a tuple can grow, and you may not read what grew", () => {
const pair: [string, number] = ["Ada", 36];
pair.push("extra");
assertStrictEquals(pair.length, 3);
assertEquals(Array.from(pair), ["Ada", 36, "extra"]);
const ro: readonly [string, number] = ["Ada", 36];
// @ts-expect-error: Property 'push' does not exist on type 'readonly [string, number]'.
void ro.push;
const two: 2 = ro.length;
assertStrictEquals(two, 2);
});
a tuple can grow, and you may not read what grew ... ok (25µs)
A readonly tuple has no push at all, which the pin records, and its .length is the literal type 2 rather than number, so the length is something the checker knows rather than something it tracks. Which makes readonly the sensible default on a tuple rather than a flourish: a fixed layout that can grow was never what you meant. The mutation side of that story is the mutating arrays page's subject.
an Array-like object is not iterable
Deno.test("an Array-like object is not iterable", () => {
const arrayLike = { length: 2, 0: "sun", 1: "moon" };
assertEquals(Array.from(arrayLike), ["sun", "moon"]);
assertThrows(
() => {
// @ts-expect-error: Type '{ length: number; 0: string; 1: string; }' must have a '[Symbol.iterator]()' method that returns an iterator.
[...arrayLike];
},
TypeError,
"arrayLike is not iterable",
);
});
an Array-like object is not iterable ... ok (298µs)
An Array-like value is any object with a length and properties named 0, 1, and so on: not an array, no methods, and Array.from accepts one and produces a real array with the elements typed correctly, while spread refuses, because spread goes through the iteration protocol and this object has none. That is the exact pair the iterables and iterators page measured in Array.from also accepts what is not iterable, seen here from the array side. TypeScript names the shape ArrayLike<T>, an interface with a length and a number index signature, worth recognising in a signature. These were common before iteration existed and are rare now: arguments is one, older DOM collections were, and both have better replacements.
creating an array from a length
Deno.test("creating an array from a length", () => {
assertStrictEquals(new Array(3).length, 3);
assertEquals(Object.keys(new Array(3)), []);
assertEquals(new Array(3).fill(0), [0, 0, 0]);
assertEquals(Array.of(3), [3]);
assertThrows(() => new Array(-1), RangeError, "Invalid array length");
});
creating an array from a length ... ok (65µs)
new Array(3) gives an array of length 3 containing three holes, which fill then makes real. Two traps in the constructor itself: a single number is a length while any other argument list is the elements, so Array.of(3) is the way to get a one-element array, and an invalid length throws RangeError rather than doing something quiet. The type is a third trap: new Array(3) is any[], so nothing about what you put in it is checked afterwards, and new Array<number>(3) brings the checker back.
Deno's linter agrees about the constructor and says so by default. Three spellings in a scratch file:
export const sized = new Array(3);
export const empty = new Array();
export const two = new Array(3, 4);
error[no-array-constructor]: Array Constructor is not allowed
--> probe.ts:2:22
|
2 | export const empty = new Array();
| ^^^^^^^^^^^
= hint: Use array literal notation (e.g. []) or single argument specifying array size only (e.g. new Array(5)
docs: https://docs.deno.com/lint/rules/no-array-constructor
error[no-array-constructor]: Array Constructor is not allowed
--> probe.ts:3:20
|
3 | export const two = new Array(3, 4);
| ^^^^^^^^^^^^^^^
= hint: Use array literal notation (e.g. []) or single argument specifying array size only (e.g. new Array(5)
docs: https://docs.deno.com/lint/rules/no-array-constructor
The rule permits exactly the useful case: one argument as a length is fine, and an empty constructor or a list of elements should have been a literal. Delete the scratch file.
fill shares one object
Deno.test("fill shares one object", () => {
const shared = new Array<Record<string, boolean>>(3).fill({});
shared[0].flag = true;
assertEquals(shared, [{ flag: true }, { flag: true }, { flag: true }]);
const separate = Array.from(
{ length: 3 },
() => ({}) as Record<string, boolean>,
);
separate[0].flag = true;
assertEquals(separate, [{ flag: true }, {}, {}]);
let called = 0;
const mapped = new Array(3).map(() => {
called++;
return {};
});
assertStrictEquals(called, 0);
assertEquals(Object.keys(mapped), []);
});
fill shares one object ... ok (45µs)
fill takes a value, not a recipe. Given an object it puts that object in every position, so the array holds three references to one thing, and a write through any of them is a write through all of them: fine for 0 or "", wrong for anything you intend to modify. Array.from({length: n}, callback) calls the callback once per element, so each gets its own object, and its first argument being an Array-like avoids building a throwaway array of holes on the way.
The last third is the obvious alternative not working. map preserves holes and does not call the callback for them, so mapping over an array of holes produces an array of holes and runs nothing: called stayed 0. It is a quiet failure, and it is the hole rules from earlier arriving in code somebody would plausibly write.
three ways to ask whether it is an array
Deno.test("three ways to ask whether it is an array", () => {
assert(Array.isArray([]));
assert([] instanceof Array);
assertStrictEquals(typeof [], "object");
function widthOf(value: unknown): number {
if (Array.isArray(value)) return value.length;
return 0;
}
assertStrictEquals(widthOf([1, 2]), 2);
assertStrictEquals(widthOf("not an array"), 0);
});
three ways to ask whether it is an array ... ok (24µs)
typeof is no help: every array is an object and so is everything else that is not a primitive. instanceof works by walking the prototype chain, from the prototypes and inheritance page, which fails for an array that came from another realm, such as a browser iframe, because that realm has its own Array with its own prototype. Array.isArray asks what the value is rather than what it inherits from, so it survives that. Worth being precise about the scope of the problem: the designing error types page found that a Deno Worker boundary rebuilds values with the receiving side's constructors, so the realm failure is a browser-with-frames fact rather than one Deno code meets. Use isArray anyway, because it is also shorter and says what you mean.
There is a cost on the type side, visible in widthOf: Array.isArray narrows an unknown to any[], not unknown[], so the elements arrive as any and the checker stops helping inside the branch, the usual bargain from the any, unknown, and never page. If you are going to touch the elements, narrow further before you do.
ranges and grids
Deno.test("ranges and grids", () => {
const range = Array.from({ length: 3 }, (_, i) => i + 2);
assertEquals(range, [2, 3, 4]);
const grid = Array.from({ length: 2 }, () => new Array(3).fill(0));
grid[0][1] = 9;
assertEquals(grid, [[0, 9, 0], [0, 0, 0]]);
});
ranges and grids ... ok (52µs)
Array.from({length: n}, callback) is the tool for an array of n distinct things, and for an integer range. There are no multidimensional arrays, only arrays of arrays, and the grid is the whole technique: the outer array must be built with a callback for the same reason as the previous step, because new Array(2).fill(new Array(3)) would give you one row twice, and grid[0][1] = 9 would change both. The assertion proves one row changed, not both.
In practice
- Prefer a literal. Use
new Array(n)only when the length is genuinely the only thing you have, and annotate it when you do. - Use
.at(-1)for the last element; a negative bracket creates a property instead. - Write
lengthonly deliberately, such as to empty an array that other code already holds. - Avoid holes, and be suspicious of an array whose
lengthis larger than its contents. - Use a
readonlytuple when positions have different meanings, andT[]when they do not. - Use
Array.from({length: n}, callback)for distinct things, ranges, and grids.
Related
- Transforming arrays covers the callback methods.
- Mutating arrays covers which operations change an array.
- Typed arrays such as
Uint8Arrayare for elements that are numbers and nothing else, and are a different subject.