Emmanuel Akinfulubi

Lesson 07 of 1815 min read

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

T[] and Array<T> are identical

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.

With the flag on, indexing admits it might find nothing

TS2322
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'.

Four honest ways to deal with it

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


      

Iteration is not affected

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.

The compiler does not track the loop bound

TS2532
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'.

Which is another argument for for-of

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.

A tuple knows what is at each position

// 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


      

Reaching past the end of a tuple

TS2493
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'.

Wrong length, wrong types

TS2322
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

Names for documentation, positions for behaviour

// 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

Tuples can have optional tails and variable ends

// 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

readonly removes the mutating methods

// 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


      

The mutating methods are simply not there

TS2339
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[]'.

Mutable flows into readonly, not the other way

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


      

readonly cannot be widened back to mutable

TS4104
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

as const freezes both the length and the values

// 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


      

Without as const, the literals are already gone

TS2345
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

find and filter behave differently, and both are correct

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


      

find might not find, and you have to say what then

TS18048
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.

Inference handles the straightforward cases

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.

Extra logic in the predicate defeats the inference

TS2322
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[]'.

And filter(Boolean) has never worked

TS2322
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[]'.

Three ways through, when inference will not help

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.

The same data, three ways, and the readability difference

// 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

  1. Write const first = [1, 2, 3][0]; and hover it, or annotate it as number, to see what noUncheckedIndexedAccess does. Then fix it three different ways.
  2. Write a classic for (let i = 0; ...) loop summing an array and note the error. Rewrite it as for...of and note that the error is gone.
  3. 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.
  4. Write a function taking readonly string[] and try to push inside it. Then call it with a plain string[] and confirm that works.
  5. Build the as const plus (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.
  6. Take an array of (number | undefined) and get a number[] out of it, first with flatMap and then with a type predicate.

Recap

  • T[] and Array<T> are the same. Use T[].
  • noUncheckedIndexedAccess makes arr[i] return T | undefined, which is the truth. Iteration is unaffected, so preferring for...of and map avoids 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 const produces a readonly tuple of literal types, which is what makes const X = [...] as const plus (typeof X)[number] the standard way to declare a fixed set of values.
  • find returns T | undefined. filter now narrows automatically when the predicate is a pure type check, but not for filter(Boolean) or a predicate carrying extra conditions — so keep narrowing filters pure and chain a second one.

Saved in this browser only.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.