Lesson 5 of 20

Arrays & Tuples

Typed Arrays: One Type for Every Element

An array type says what kind of thing the array holds. There are two ways to write it and they mean exactly the same: number[] and Array<number>. The square-bracket form is shorter and far more common, so use it by default; the angle-bracket form reads better for long or nested element types, such as Array<{ id: number; name: string }>.

Once an array is typed, every operation on it is checked. Pushing the wrong type is an error, and so is assigning the wrong type into a position. In practice you will rarely write the annotation at all — a non-empty array literal infers its own type, and const marks = [88, 74, 91] is already number[]. The exception, as always, is the empty array, which has nothing to infer from.

One piece of syntax trips people up regularly. In a union, the [] binds to the type immediately before it. string | number[] means "either a string, or an array of numbers" — almost certainly not what you meant. To describe an array whose elements are strings or numbers you need brackets around the union: (string | number)[].

Example
let marks: number[] = [88, 74, 91];
let subjects: Array<string> = ["Physics", "Maths"];

marks.push(65);      // OK
marks.push("65");    // ERROR: Argument of type 'string' is not assignable
                     //        to parameter of type 'number'.

// Inference handles the common case
const cities = ["Pune", "Kochi", "Indore"];   // string[]

// Empty arrays need an annotation — there is nothing to infer from
const selected: string[] = [];

// Precedence trap
let a: string | number[];      // a string, OR an array of numbers
let b: (string | number)[];    // an array of strings and numbers

b = [1, "two", 3];   // OK
a = [1, "two", 3];   // ERROR
Notes
  • An array of objects is typed the same way: User[] once you have a User interface, or { id: number; name: string }[] written inline. Typing the response of an API that returns a list is the single most common use of array types in real code.

Array Methods Carry Their Types Through

The real payoff of typed arrays is that the built-in methods know what they are working with. Inside map, filter or forEach, the callback parameter is already typed — that is contextual typing again — so you get autocomplete on it and you should not annotate it.

More importantly, the return type of map is derived from what your callback returns, and that flows onwards through your program. Map a User[] to u => u.name and you have a string[]; map it to u => ({ label: u.name }) and you have an array of objects. If a bug creeps in and one branch of your callback returns undefined, the result becomes (string | undefined)[] and the error appears at whichever line assumes plain strings. Reading the inferred type of a map result is often the fastest way to spot that kind of mistake.

Two methods deserve a warning. find returns T | undefined because it might find nothing, and under strict you must handle that — this is TypeScript doing its job, not being annoying. And reduce without an initial value compiles fine but throws at runtime on an empty array; always pass the initial value, which also tells TypeScript what the accumulator's type is.

Example
interface Student {
  id: number;
  name: string;
  marks: number;
}

const students: Student[] = [
  { id: 1, name: "Ananya", marks: 88 },
  { id: 2, name: "Rahul", marks: 64 }
];

// s is already Student — do not annotate it
const names = students.map(s => s.name);          // string[]
const toppers = students.filter(s => s.marks > 75); // Student[]

// The callback's return type decides the array's type
const rows = students.map(s => ({ label: s.name, pass: s.marks >= 33 }));
// rows: { label: string; pass: boolean }[]

// find can fail, so the type says so
const found = students.find(s => s.id === 5);   // Student | undefined
// console.log(found.name);   ERROR: 'found' is possibly 'undefined'.
if (found) console.log(found.name);

// Always give reduce an initial value
const total = students.reduce((sum, s) => sum + s.marks, 0);   // number
Notes
  • sort() is a reminder that types are not logic. It mutates the array in place and, with no comparator, sorts by string order — so [10, 9, 100].sort() gives [10, 100, 9]. TypeScript will not say a word, because nothing about that is a type error.

The Hole in Array Indexing

Here is a gap that surprises people, and it is deliberate. By default, indexing an array gives you the element type with no hint that the index might be out of range. marks[99] on a three-element array is typed number, so calling .toFixed() on it compiles happily and throws at runtime.

TypeScript behaves this way because the alternative — treating every array access as possibly undefined — would force a check on every single loop body and index lookup, and the team judged that too noisy for the default. It is a trade-off, not an oversight.

If you want the safer behaviour there is a compiler option for it: noUncheckedIndexedAccess. Turn it on and every index access on an array or an index-signature object is typed T | undefined, so the compiler makes you handle the missing case. It catches real bugs, particularly around splitting strings and reading query parameters, and it does add work — expect to write more checks or to use at() and destructuring with defaults. Turn it on early in a project rather than late.

The same caution applies to Object.keys and destructuring: const [first] = [] as string[] gives you a string according to the type system and undefined in reality.

Example
const marks: number[] = [88, 74, 91];

const m = marks[99];      // typed number — no warning at all
// m.toFixed(1);          // runtime: Cannot read properties of undefined

// With "noUncheckedIndexedAccess": true in tsconfig.json
const m2 = marks[99];     // number | undefined
// m2.toFixed(1);         // ERROR: 'm2' is possibly 'undefined'.
if (m2 !== undefined) console.log(m2.toFixed(1));

// A very common real bug this catches
function extension(filename: string): string {
  const parts = filename.split(".");
  return parts[parts.length - 1];   // undefined when there is no dot
}

// Safer, and works with the flag on
function extensionSafe(filename: string): string {
  const parts = filename.split(".");
  return parts.length > 1 ? parts[parts.length - 1]! : "";
}
Notes
  • noUncheckedIndexedAccess is not included in strict. If you want it, you have to add it to tsconfig.json yourself.

Tuples: Fixed Length, Meaningful Positions

A tuple is an array where the length is fixed and each position has its own type. [string, number] is a two-element array whose first element is a string and whose second is a number, in that order. Get the order wrong and it is an error; supply three elements and it is an error.

The most familiar tuple in real code is React's useState, which returns the current value and the setter as a pair, so that you can name both at the call site: const [count, setCount] = useState(0). That works because destructuring an array assigns by position, and the tuple type tells TypeScript that position 0 is a number and position 1 is a setter function. Without tuples, that return value would have to be typed as a union array and every use would need narrowing.

Because positions are meaningless to a reader, TypeScript lets you name tuple elements. The names are purely for documentation and editor hints — they do not change the type or force you to use those names when destructuring — but they turn [string, number, boolean] into something you can understand at a glance. Tuples also support optional trailing elements with ?, and a rest element for "and then any number of these".

Example
// Order and length are both part of the type
let entry: [string, number] = ["Ananya", 88];

const who = entry[0];    // string
const what = entry[1];   // number

let wrong: [string, number] = [88, "Ananya"];
// ERROR: Type 'number' is not assignable to type 'string'.

// Named elements — documentation for humans
type Coordinate = [latitude: number, longitude: number];
const office: Coordinate = [19.0760, 72.8777];

// Optional trailing element
type Point = [x: number, y: number, z?: number];
const flat: Point = [10, 20];        // OK
const solid: Point = [10, 20, 30];   // OK

// Rest element: a label followed by any number of scores
type ScoreRow = [name: string, ...scores: number[]];
const row: ScoreRow = ["Rahul", 64, 71, 80];

// Destructuring is where tuples earn their keep
const [name, marks] = entry;   // name: string, marks: number

Tuple Gotchas, and When to Use an Object Instead

Tuples are arrays underneath, and that leaks. A tuple still has every array method, including push — so you can push a fourth element onto a three-element tuple and TypeScript will allow it, because push is typed to accept any of the tuple's element types. The fixed length is enforced when you create or assign the tuple, not for the whole of its life. Marking the tuple readonly closes this hole properly, since a readonly tuple has no mutating methods at all.

The bigger question is when to use a tuple rather than an object. The honest answer is: rarely. A tuple asks the reader to remember what position 2 means, and it breaks silently if someone reorders the values. An object names its parts, survives reordering, and can gain a new field without touching any existing call site.

Reach for a tuple when the values genuinely have no names — a coordinate pair, a min and max, a row from a CSV — or when a convention already exists, as with useState or Object.entries. Everything else is better as an object. If you find yourself writing a comment explaining what each position of a tuple holds, that comment is telling you to use an object.

Example
const pair: [string, number] = ["Ananya", 88];

pair.push("extra");   // allowed! Tuples still have array methods.
console.log(pair.length);   // 3 at runtime, but the type still says 2

// readonly closes the hole
const locked: readonly [string, number] = ["Ananya", 88];
// locked.push("extra");   ERROR: Property 'push' does not exist
// locked[0] = "Rahul";    ERROR: Cannot assign to '0' because it is read-only

// Hard to read — what is position 2?
function parseRow(row: string): [string, number, boolean] {
  const [name, marks] = row.split(",");
  return [name, Number(marks), Number(marks) >= 33];
}

// Better in almost every case
interface Row {
  name: string;
  marks: number;
  passed: boolean;
}

function parseRowBetter(row: string): Row {
  const [name, marks] = row.split(",");
  return { name, marks: Number(marks), passed: Number(marks) >= 33 };
}
Notes
  • Object.entries(obj) gives you [string, T][] — an array of key/value tuples — which is why for (const [key, value] of Object.entries(scores)) types both variables correctly.

readonly Arrays and as const

readonly string[] — or its longer name ReadonlyArray<string> — is an array you may read but not change. The mutating methods simply do not exist on the type, so push, pop, splice and index assignment are all compile errors, while map, filter and find work as normal.

This is most useful on function parameters. Declaring function summarise(marks: readonly number[]) is a promise to your callers that you will not modify the array they handed you — a promise the compiler now enforces on you. Accidentally sorting or reversing a caller's array in place is a genuinely common bug, and it is one the type system can rule out for free.

Assignability runs one way only, and the direction is the useful one: a normal string[] can be passed where a readonly string[] is expected, because promising not to modify it is a restriction you are free to accept. The reverse is rejected — a readonly array cannot be passed to something that expects to mutate it. If you get an error about that, the fix is usually to widen the parameter to readonly, not to strip the readonly off the data.

as const applied to an array literal produces a readonly tuple of literal types, which is how you turn a plain list of strings into a union type you can use elsewhere. It is the modern replacement for a lot of what enums were used for.

Example
const colors: readonly string[] = ["red", "green", "blue"];
// colors.push("yellow");   ERROR: Property 'push' does not exist
const upper = colors.map(c => c.toUpperCase());   // reading is fine

// A promise the compiler enforces
function highest(marks: readonly number[]): number {
  // marks.sort();   ERROR — and that is the point: sort() mutates
  return [...marks].sort((a, b) => b - a)[0];
}

const mutable: number[] = [3, 1, 2];
highest(mutable);   // OK — string[] fits readonly string[]

// as const: literal types plus readonly, in one step
const STATUSES = ["placed", "packed", "shipped"] as const;
// type: readonly ["placed", "packed", "shipped"]

type Status = typeof STATUSES[number];   // "placed" | "packed" | "shipped"

let s: Status = "packed";   // OK
let t: Status = "lost";     // ERROR
Notes
  • One rough edge with as const lists: STATUSES.includes(someString) is an error, because includes expects one of the three literal values and you handed it a general string. The usual fix is a helper typed to take a string and return a type predicate — the lesson on type guards shows how.
Ask AI