Arrays, Tuples and readonly
Ordered data, fixed-length data, and how to say "do not modify this" in a language that cannot actually stop you.
By the end: Type arrays and tuples, and use readonly where it helps.
Arrays are the easy part. The interesting material in this lesson is the flag that changes how array access is typed, and the difference between a list of things and a fixed set of positions.
Two spellings, one type
const names: string[] = ['Ada', 'Grace'];
const scores: Array<number> = [10, 8];
// Nested: an array of arrays of numbers.
const grid: number[][] = [
[1, 2],
[3, 4],
];
// A union needs brackets, or you get something you did not mean.
const mixed: (string | number)[] = ['a', 1];
console.log(names.join(', '));
console.log(scores.reduce((a, b) => a + b, 0));
console.log(grid.map((row) => row.length));
console.log(mixed);
Output
The flag that makes indexing honest
Here is the most consequential thing in this lesson. In plain TypeScript, values[0] on a number[] is typed number. That is a lie, and everybody knows it is a lie: the array might be empty, and the answer would be undefined.
noUncheckedIndexedAccess stops the lie. With it on, every index access returns T | undefined, and you have to say what you want to happen when there is nothing there.
const values: number[] = [1, 2, 3];
// values[0] is number | undefined, because the compiler has no idea how long
// the array is. Assigning that to a plain number is rejected.
const first: number = values[0];
console.log(first);
What the compiler says
error TS2322: Type 'number | undefined' is not assignable to type 'number'.
const values: number[] = [1, 2, 3];
const empty: number[] = [];
// 1. Check. Most explicit, and forces you to decide what absence means.
const a = values[0];
console.log('checked:', a === undefined ? 'empty' : a * 2);
// 2. Default with ??, when there is a sensible fallback.
const b = values[0] ?? 0;
console.log('defaulted:', b * 2);
// 3. at(), which is honest about its type and handles negative indices.
console.log('last:', values.at(-1) ?? 'none');
console.log('last of empty:', empty.at(-1) ?? 'none');
// 4. Assert, when you have information the compiler cannot have.
// Here the literal on the line above proves the array is non-empty.
const three = [7, 8, 9];
console.log('asserted:', three[0]! + 1);
// And this is what the assertion costs you when you are wrong: no error,
// just undefined travelling onwards to cause trouble somewhere else.
console.log('wrongly asserted:', empty[0]! ?? 'silently undefined');
Output
const values: number[] = [1, 2, 3];
// Every one of these sees `number`, with no undefined in sight.
for (const value of values) console.log('for-of:', value * 2);
console.log('map:', values.map((v) => v * 2));
console.log('filter:', values.filter((v) => v > 1));
console.log('reduce:', values.reduce((sum, v) => sum + v, 0));
// entries() when you need the index as well.
for (const [i, value] of values.entries()) console.log(i, value);
Output
One place the flag genuinely does cost you: a classic indexed loop over an array now needs an assertion or a check, because the compiler does not connect i < values.length to values[i] being present.
const values: number[] = [1, 2, 3];
let total = 0;
for (let i = 0; i < values.length; i++) {
// You and I can see this is in range. The compiler cannot: it does not
// relate the loop condition to the index access.
total += values[i].valueOf();
}
console.log(total);
What the compiler says
error TS2532: Object is possibly 'undefined'.
const values: number[] = [1, 2, 3];
// The indexed version, with the assertion the compiler asked for.
let indexed = 0;
for (let i = 0; i < values.length; i++) indexed += values[i]!;
// The version that needed no assertion at all.
let iterated = 0;
for (const value of values) iterated += value;
console.log(indexed, iterated);
console.log('Same answer. The second one did not have to argue.');
Output
Tuples: fixed length, typed positions
An array says “any number of these”. A tuple says “exactly this many, in this order, with these types”. That is the right description for a coordinate pair, a key-value entry, or anything you would destructure positionally.
// Exactly two numbers.
type Point = [number, number];
// Mixed types per position.
type Entry = [string, number];
const origin: Point = [0, 0];
const pair: Entry = ['count', 42];
// Destructuring gets the right type for each position.
const [x, y] = origin;
const [key, value] = pair;
console.log(x + y);
console.log(`${key}=${value}`);
// Indexing a tuple is precise, and needs no undefined check, because the
// compiler knows the length.
console.log(origin[0] + origin[1]);
console.log(pair[0].toUpperCase(), pair[1].toFixed(0));
Output
type Point = [number, number];
const origin: Point = [0, 0];
// There is no index 2. The compiler knows exactly how long this is.
console.log(origin[2]);
What the compiler says
error TS2493: Tuple type 'Point' of length '2' has no element at index '2'.
type Point = [number, number];
// Three elements where two are required.
const tooMany: Point = [1, 2, 3];
console.log(tooMany);
What the compiler says
error TS2322: Type '[number, number, number]' is not assignable to type 'Point'.
Named tuple elements
// The names are purely for readers and editor tooltips. This is still a
// positional tuple, and the names do not become properties.
type Range = [start: number, end: number];
function span(range: Range): number {
const [start, end] = range;
return end - start;
}
console.log(span([10, 25]));
// Signatures read much better with names, especially for rest tuples.
function move(...args: [dx: number, dy: number, label?: string]): string {
const [dx, dy, label] = args;
return `${label ?? 'move'}: ${dx},${dy}`;
}
console.log(move(1, 2));
console.log(move(1, 2, 'jump'));
Output
Optional and rest elements
// A third element that may or may not be there.
type Colour = [r: number, g: number, b: number, a?: number];
const opaque: Colour = [255, 0, 0];
const translucent: Colour = [255, 0, 0, 0.5];
console.log(opaque, translucent);
console.log('alpha:', translucent[3] ?? 1);
// A fixed head and a variable tail.
type Command = [name: string, ...args: string[]];
const cmd: Command = ['copy', 'a.txt', 'b.txt'];
const bare: Command = ['list'];
const [name, ...args] = cmd;
console.log(name, args);
console.log(bare[0], bare.slice(1));
Output
readonly arrays
// Two spellings, same meaning.
const fixed: readonly number[] = [1, 2, 3];
const alsoFixed: ReadonlyArray<string> = ['a', 'b'];
// Reading is unaffected.
console.log(fixed.length, fixed[0] ?? 'none');
console.log(alsoFixed.join('-'));
// Non-mutating methods still work, and return mutable arrays.
const doubled = fixed.map((n) => n * 2);
doubled.push(99);
console.log(doubled);
// concat, slice, filter and friends are all fine, because none of them
// modify the original.
console.log(fixed.concat([4]));
console.log(fixed.slice(1));
Output
const fixed: readonly number[] = [1, 2, 3];
// push does not exist on a readonly array. Neither do pop, splice, sort,
// reverse or fill.
fixed.push(4);
console.log(fixed);
What the compiler says
error TS2339: Property 'push' does not exist on type 'readonly number[]'.
function total(values: readonly number[]): number {
return values.reduce((sum, n) => sum + n, 0);
}
const mutable: number[] = [1, 2, 3];
const frozen: readonly number[] = [4, 5, 6];
// Both are accepted, because the function only promises to read.
console.log(total(mutable));
console.log(total(frozen));
Output
const frozen: readonly number[] = [1, 2, 3];
// Going the other way would hand out permission the original never granted.
const mutable: number[] = frozen;
console.log(mutable);
What the compiler says
error TS4104: The type 'readonly number[]' is 'readonly' and cannot be assigned to the mutable type 'number[]'.
as const, and why it produces a tuple
// Inferred as string[]: a mutable array of general strings.
const loose = ['north', 'south'];
// Inferred as readonly ['north', 'south']: a readonly tuple of literals.
const tight = ['north', 'south'] as const;
console.log(loose, tight);
// That difference matters when the literals are what you needed.
type Direction = 'north' | 'south';
function go(direction: Direction): string {
return `heading ${direction}`;
}
// tight[0] is the literal 'north', so this is accepted.
console.log(go(tight[0]));
// A neat consequence: as const gives you the union for free.
const LEVELS = ['debug', 'info', 'error'] as const;
type Level = (typeof LEVELS)[number];
function log(level: Level, message: string): string {
return `[${level}] ${message}`;
}
console.log(log('info', 'ready'));
console.log('the runtime array is still just an array:', LEVELS.join(', '));
What this compiles to
// Inferred as string[]: a mutable array of general strings.
const loose = ['north', 'south'];
// Inferred as readonly ['north', 'south']: a readonly tuple of literals.
const tight = ['north', 'south'];
console.log(loose, tight);
function go(direction) {
return `heading ${direction}`;
}
// tight[0] is the literal 'north', so this is accepted.
console.log(go(tight[0]));
// A neat consequence: as const gives you the union for free.
const LEVELS = ['debug', 'info', 'error'];
function log(level, message) {
return `[${level}] ${message}`;
}
console.log(log('info', 'ready'));
console.log('the runtime array is still just an array:', LEVELS.join(', '));
Output
type Direction = 'north' | 'south';
function go(direction: Direction): string {
return `heading ${direction}`;
}
// Inferred as string[], so the element type is string, which is not one of
// the two allowed literals.
const loose = ['north', 'south'];
console.log(go(loose[0]!));
What the compiler says
error TS2345: Argument of type 'string' is not assignable to parameter of type 'Direction'.
Array methods, and where the types get interesting
interface User {
id: number;
name: string;
active: boolean;
}
const users: User[] = [
{ id: 1, name: 'Ada', active: true },
{ id: 2, name: 'Grace', active: false },
];
// find returns User | undefined, because it might not find anything. This is
// true regardless of noUncheckedIndexedAccess — it is in the signature.
const found = users.find((u) => u.id === 1);
console.log('found:', found?.name ?? 'nobody');
const missing = users.find((u) => u.id === 99);
console.log('missing:', missing?.name ?? 'nobody');
// filter returns User[], never undefined, because "none matched" is [].
const active = users.filter((u) => u.active);
console.log('active:', active.map((u) => u.name));
// flatMap flattens one level, and the types follow.
const letters = users.flatMap((u) => u.name.split(''));
console.log('letters:', letters.length);
Output
interface User { id: number; name: string; }
const users: User[] = [{ id: 1, name: 'Ada' }];
const found = users.find((u) => u.id === 99);
// found is User | undefined. Reaching for .name assumes success.
console.log(found.name);
What the compiler says
error TS18048: 'found' is possibly 'undefined'.
The filter that narrows, and the one that does not
Filtering the empty values out of an array used to be a well-known annoyance: the compiler could not tell that your predicate was a type check, so (string | undefined)[] stayed (string | undefined)[] however obvious the filter was. Modern TypeScript fixes this by inferring the type predicate for you, and it works well — within limits worth knowing exactly.
const maybe: (string | undefined)[] = ['a', undefined, 'b'];
// The compiler reads this predicate, sees that it is purely a type check,
// and narrows the result to string[] on its own.
const definitely: string[] = maybe.filter((v) => v !== undefined);
console.log('inferred:', definitely.map((s) => s.toUpperCase()));
// != null works the same way, and removes both null and undefined.
const messy: (string | null | undefined)[] = ['a', null, undefined, 'b'];
const clean: string[] = messy.filter((v) => v != null);
console.log('!= null: ', clean);
// It even follows a named function, as long as that function is a pure check.
const present = (v: string | undefined) => v !== undefined;
const viaNamed: string[] = maybe.filter(present);
console.log('named: ', viaNamed);
Output
Now the boundary. Inference only applies when the predicate does nothing except decide a type. Add any extra condition and the compiler stops, because the function is no longer purely a type test.
const maybe: (string | undefined)[] = ['a', undefined, 'b'];
// The `&& v.length > 0` makes this a filter that happens to include a type
// check, rather than a type check. The compiler will not infer a predicate.
const definitely: string[] = maybe.filter((v) => v !== undefined && v.length > 0);
console.log(definitely);
What the compiler says
error TS2322: Type '(string | undefined)[]' is not assignable to type 'string[]'.
const maybe: (string | undefined)[] = ['a', undefined, 'b'];
// A perennial disappointment. Boolean is a general-purpose function with no
// relationship to this array's element type, so there is nothing to infer.
// `filter((v) => v)` fails for the same reason.
const definitely: string[] = maybe.filter(Boolean);
console.log(definitely);
What the compiler says
error TS2322: Type '(string | undefined)[]' is not assignable to type 'string[]'.
const maybe: (string | undefined)[] = ['a', undefined, 'b', ''];
// 1. Write the predicate out explicitly. Lesson 13 covers the `v is T` syntax;
// this is the escape hatch when your predicate does more than one thing.
const viaPredicate = maybe.filter((v): v is string => v !== undefined && v.length > 0);
console.log('predicate:', viaPredicate);
// 2. flatMap, which drops an element by returning an empty array. No new
// syntax required, and it reads well for filter-and-transform in one step.
const viaFlatMap = maybe.flatMap((v) => (v ? [v.toUpperCase()] : []));
console.log('flatMap: ', viaFlatMap);
// 3. Filter first, then apply the rest. Each step is simple enough to infer.
const viaTwoSteps = maybe.filter((v) => v !== undefined).filter((v) => v.length > 0);
console.log('two steps:', viaTwoSteps);
Output
Choosing between an array, a tuple and an object
A short decision rule, because using the wrong one is a common source of awkward code.
- Array when you have any number of the same kind of thing and position carries no meaning beyond order.
- Tuple when there is a fixed, small number of positions and each position means something different. Two or three elements is comfortable; beyond that, positions stop being memorable.
- Object as soon as you find yourself writing a comment to explain what index 2 is. Named properties are self-documenting and survive reordering.
// Tuple: fine at two elements.
type Coord = [x: number, y: number];
const point: Coord = [3, 4];
console.log('tuple:', point[0], point[1]);
// Tuple: already too much at five.
type BadRow = [string, number, boolean, string, number];
const bad: BadRow = ['Ada', 36, true, 'London', 1815];
console.log('what is index 4 again?', bad[4]);
// Object: obvious, reorderable, and greppable.
interface Person {
name: string;
age: number;
active: boolean;
city: string;
born: number;
}
const good: Person = { name: 'Ada', age: 36, active: true, city: 'London', born: 1815 };
console.log('object:', good.born);
Output
Your turn
- Write
const first = [1, 2, 3][0];and hover it, or annotate it asnumber, to see whatnoUncheckedIndexedAccessdoes. Then fix it three different ways. - Write a classic
for (let i = 0; ...)loop summing an array and note the error. Rewrite it asfor...ofand note that the error is gone. - Write a tuple type
[status: number, body: string]and a function returning one. Confirm that indexing it needs no undefined check, and try reading index 2. - Write a function taking
readonly string[]and try topushinside it. Then call it with a plainstring[]and confirm that works. - Build the
as constplus(typeof X)[number]pattern for the four suits of a card deck. Confirm you can both loop over the array and use the type in a signature. - Take an array of
(number | undefined)and get anumber[]out of it, first withflatMapand then with a type predicate.
Recap
T[]andArray<T>are the same. UseT[].noUncheckedIndexedAccessmakesarr[i]returnT | undefined, which is the truth. Iteration is unaffected, so preferringfor...ofandmapavoids the friction entirely.- A tuple has a known length, so indexing it stays clean and is checked against the actual positions.
readonly T[]removes the mutating methods and is a cheap, checked promise on a parameter. A mutable array flows into it; the reverse does not.as constproduces a readonly tuple of literal types, which is what makesconst X = [...] as constplus(typeof X)[number]the standard way to declare a fixed set of values.findreturnsT | undefined.filternow narrows automatically when the predicate is a pure type check, but not forfilter(Boolean)or a predicate carrying extra conditions — so keep narrowing filters pure and chain a second one.