Before you begin
Why this matters
Most programs keep values in arrays, and most array bugs are about positions: a loop that reads one position past the end, a loop that stops one position early, or a saved position that now finds a different element. Knowing how positions work lets you predict each of these before it happens.
What this helps you reason about
- Find the last element of an array without going one step too far.
- Predict what reading past the end of an array gives, and spot the bug it hides.
- Tell which changes move the other elements, and keep a saved position correct.
At a station, taxis wait in a queue in the order they arrive, and the first taxi in the queue leaves first. This lesson keeps the taxis in a TypeScript array and asks how a position finds a taxi, how the array grows, and what a position past the end gives.
Already know this? The short version
- Indexes run from 0 to length − 1: queue[queue.length - 1] is the last element, and so is queue.at(-1) (ES2022).
- A TypeScript array always grows and shrinks (push, pop, unshift, shift, splice); const fixes the variable, not the length. Typed arrays such as Uint8Array are the fixed-length kind.
- Node.js keeps spare capacity and copies the elements to a larger block when it runs out, so push has a small amortized cost; [...queue, x] copies every element every time.
- An index past the end reads undefined and does not throw; a write past the end grows the array, with empty slots in between.
- unshift, shift, and splice move every later element, so a saved index can name a different element afterwards.
Words in this lesson (6)
- index
- The position of an element in an ordered collection, counted from 0. The first element has index 0, and the last has index length − 1.
- array
- An object that holds elements in order, written with brackets, such as [12, 7, 31]. queue.length is its number of elements, and it can grow and shrink.
- capacity
- How many elements fit in the block that holds a collection’s elements, before adding one more needs a larger block. TypeScript code cannot read an array’s capacity; Node.js manages it.
- amortized cost
- The average cost of one operation over a long run of them. Adding to a growable list sometimes copies every element, but those copies become rarer as it grows, so the average cost of one addition stays small.
- undefined
- A special JavaScript value that means “no value yet”. A let variable declared without a value holds undefined.
- off-by-one error
- A mistake where a loop or a count goes one step too far or stops one step too early, such as a loop that never reaches the last element of an array.
Examples are focused snippets; imports, enclosing functions, or classes may be omitted. C examples use C11; Haskell examples use Haskell 2010. Expand the notes for language-specific depth.
01 / Positions
How does a collection find the element at a position?
An array keeps its elements in order. Each element has a position, its index, counted from 0. In [12, 7, 31], taxi 12 is at index 0, taxi 7 at index 1, and taxi 31 at index 2. queue[i] reads the element at index i.
rank.length is the number of elements, 3 here. Because counting starts at 0, the last index is always one less than the length: queue[queue.length - 1] is the last taxi, however many there are.
Reading an element by its index takes one step, not a search. Picture the elements stored side by side, so the program can work out where element i is from i alone.
const queue = [12, 7, 31];
console.log(queue[0]); // 12
console.log(queue.length); // 3
console.log(queue[queue.length - 1]); // 31 Indexes run from 0 to length − 1; the last element is at length − 1.
Coming from Python: queue[-1] is not the last element: it is undefined, because -1 is not an index. Write queue[queue.length - 1], or queue.at(-1).
Precisely: what “side by side” means here
The specification describes an array as an object whose property names are the indexes, plus a length. Node.js’s V8 engine stores an array of numbers like queue as a block of elements side by side. An array with large gaps can be stored as a table instead; reading an index still works, but it is a lookup, not one step.
Experiment
Read the first and the last taxi
The first element is at index 0. The length is 3, so the last index is 3 - 1 = 2. After a fourth taxi is added, the length is 4, and the same expression reads index 3.
Why it matters Compute the last index from the length, as length - 1, so it stays right when the number of elements changes.
Predict the three printed numbers. Then add the line queue.push(25); after the first line, and predict again.
const queue = [12, 7, 31];
const first = queue[0];
const last = queue[queue.length - 1];
console.log(queue.length, first, last);
What to look for
queue shows [12, 7, 31]; first shows 12, last shows 31, and the run prints 3 12 31. The output stays empty at the stops on the console.log line, because the stepper stops before a line runs, sometimes twice on one line. After the edit, queue shows [12, 7, 31, 25] after the push, last shows 25, and the run prints 4 12 25.
Expected output
3 12 3102 / Fixed or growable
What stays fixed, and what can grow?
Many languages have two kinds of indexed collection. A fixed-size array has a length that never changes after it is made. A growable list can add and remove elements.
A TypeScript array is always the growable kind. push adds an element at the end, and pop removes the last element and returns it. Both change the array and its length. const queue stops queue from being given a different array, but push and pop still change this one.
const queue = [12, 7, 31];
queue.push(25); // [12, 7, 31, 25]
const gone = queue.pop(); // 25; queue is [12, 7, 31] again
console.log(queue.length, gone); // 3 25 A TypeScript array always grows and shrinks; const does not fix its length.
Coming from Java: There is no separate ArrayList: the array itself grows. new Array(5) makes an array of length 5 with empty slots, not an empty array with space for 5.
Precisely: fixed-length arrays in JavaScript
Typed arrays, such as Uint8Array and Float64Array, have a fixed length and hold numbers of one kind, like the arrays of C, Go, Rust, and Java. A readonly number[] type stops push and pop only while the compiler checks the code.
03 / Growing
What does it cost to grow?
Growing has a cost that the code does not show. Picture the elements in one block of memory, with some unused space after them. The number of elements that fit is the block’s capacity. push writes into the unused space. When the space runs out, Node.js gets a larger block, copies every element into it, and stops using the old block.
A copy of every element sounds costly. But the new block has space for many more elements, so the copies become rarer as the array grows. Averaged over many pushes, each push costs a small amount. This average is the amortized cost.
Code that builds a new array for every addition pays for a copy every time. [...queue, 25] is spread syntax: it makes a new array with queue’s elements, then 25. In a loop that adds one taxi per round, it copies all earlier taxis in every round.
let queue = [12, 7, 31];
queue.push(25); // same array; copies only when its space runs out
queue = [...queue, 40]; // a new array: copies 4 taxis, adds 40 push copies the elements now and then; a new array for each addition copies them every time.
Coming from React: React state uses [...items, x] on purpose: a new array is a new reference, so React sees the change. The copy is the cost of that, and it is small for short lists.
Precisely: how much space
The specification does not mention capacity; it belongs to the engine. V8, the engine inside Node.js, grows a full block to about one and a half times its length, plus 16 elements. No code can read the capacity, and it is not a rule of the language.
Experiment
Push onto an array, or build a new one
push adds to the array that both names lead to. When that array has run out of unused space, Node.js copies its elements to a larger block, but it is still the same array, so === stays true. [...queue, 25] always makes a new array and copies every element into it.
Why it matters push copies the elements only now and then. Building a new array for each addition copies them every time: use it when you need a second array, not to add many elements one by one.
Predict both printed values. Then change const queue to let queue, change queue.push(25); to queue = [...queue, 25];, and predict again.
const queue = [12, 7, 31];
const before = queue;
queue.push(25);
console.log(queue === before, before.length);
What to look for
After the push, queue and before both show [12, 7, 31, 25], and the run prints true 4: queue === before is true when both names lead to the same array, which the panel cannot show. After the edit, queue shows [12, 7, 31, 25] while before keeps [12, 7, 31], and the run prints false 3.
Expected output
true 404 / Out of range
What happens at an index past the end?
An index past the end does not stop a TypeScript program. Reading queue[3] from a three-element array gives undefined, “no value yet”, and the program continues with it. Added to a number it gives NaN, and in text it prints as undefined, often far from the line that caused it.
The usual cause is an off-by-one error: a loop that runs one step too far. for (let i = 0; i < queue.length; i++) starts i at 0, runs the block while i < queue.length, and adds 1 to i after each round. With <= in place of <, the last round reads index 3, where nothing is stored.
const queue = [12, 7, 31];
for (let i = 0; i < queue.length; i++) {
console.log(i, queue[i]);
}
console.log(queue[3]); // undefined Reading past the end gives undefined, not an error; loop with i < length.
Coming from Python: Python raises IndexError for queue[3]. TypeScript gives undefined and goes on, so the mistake shows up later, in a sum or a printed line.
Precisely: writing past the end
A write past the end does not fail either. queue[5] = 40 on a three-element array makes its length 6, with empty slots at indexes 3 and 4. The compiler option noUncheckedIndexedAccess types queue[i] as number | undefined, so that the compiler asks you to handle a missing element; the stepper does not set it.
Experiment
Loop one step past the last index
With <=, the loop runs once more, with i equal to the length. No element has index 3, so queue[3] is undefined, and TypeScript does not stop the program.
Why it matters Loop with i < length, never i <= length. The last index is length - 1. In TypeScript, an undefined in your output or a NaN in a sum is often the sign of one step too far.
Predict the printed lines. Then change i < queue.length to i <= queue.length and predict again.
const queue = [12, 7, 31];
for (let i = 0; i < queue.length; i++) {
console.log(i, queue[i]);
}
What to look for
Each round stops on the for line and on the console.log line, where i shows the index about to be read; when i shows 3, the loop ends. The run prints 0 12, 1 7, and 2 31. After the edit, a fourth round reads queue[3], which gives undefined, so the run prints 3 undefined as its last line and ends normally.
Expected output
0 12
1 7
2 3105 / Front and end
What changes when you add or remove at the front?
Adding or removing at the end leaves every other element where it was. Adding or removing at the front moves all of them. unshift(40) puts 40 at index 0, and every other element moves one index later. shift() removes the element at index 0, and every other element moves one index earlier. splice(i, 1) removes the element at index i and moves the later ones the same way. Each costs one move per element that moves.
A saved index does not move with its element. queue.indexOf(7) returns the index of the first element equal to 7, here 1. After unshift(40), index 1 holds 12, and taxi 7 is at index 2. Find the element again after a change at the front or in the middle.
const queue = [12, 7, 31];
const spot = queue.indexOf(7); // 1
queue.unshift(40); // [40, 12, 7, 31]
console.log(queue[spot]); // 12, not 7
queue.shift(); // [12, 7, 31] Changes at the front move every element; a saved index then finds a different element.
Precisely: queues
A long queue that removes from the front can keep a start index instead of calling shift: the element at start is the front, and start += 1 removes it without moving anything. The specification describes shift as moving every element; V8 can sometimes avoid the move, but code should not rely on that.
Experiment
Save a position, then add at the front
Adding at the front moves 12, 7, and 31 one index later. spot still holds 1, and index 1 now holds 12. Adding at the end moves nothing, so index 1 still holds 7.
Why it matters A saved index is a place, not an element. After a change at the front or in the middle, find the element again.
Predict both printed numbers. Then change queue.unshift(40); to queue.push(40); and predict again.
const queue = [12, 7, 31];
const spot = queue.indexOf(7);
queue.unshift(40);
console.log(spot, queue[spot]);
What to look for
spot shows 1, the index of taxi 7. After unshift, queue shows [40, 12, 7, 31] while spot still shows 1, and the run prints 1 12. After the edit, queue shows [12, 7, 31, 40], and the run prints 1 7.
Expected output
1 1206 / Try it
Can you predict a fresh example?
Decide which elements move, then read each index. The next lesson, Stack and heap, shows where the blocks that hold elements are stored.
const queue = [12, 7, 31, 25];
const spot = 2; // taxi 31
queue.shift();
console.log(queue.length, queue[0], queue[spot]);
What is printed?
Reveal the reasoning
3 7 25. shift removes taxi 12 from index 0, so queue is [7, 31, 25] and its length is 3. Every taxi moved one index earlier, so index 2, saved for taxi 31, now holds taxi 25.
Practice tickets
- ARRAY-101 · The van screen shows the stop after the one the driver typed → Free
- ARRAY-102 · A cabin that needs inspection stays in service when the cabin before it also needs one → Sign in · Pro
- ARRAY-103 · New flights stop appearing in the logbook after the fifth → Sign in · Pro