Array and Tuple Types in TypeScript: readonly, Optional Elements and Named Tuples
string[] versus the generic Array form, readonly arrays, fixed-length tuples, optional and rest elements, named tuples, and how the noUncheckedIndexedAccess setting changes reads by index.
Version note: The code in this post targets TypeScript 7.0 and Node 26.
Predict First
We're storing a point's x and y coordinates in a two-element structure. The square brackets in the annotation are the topic of this post; for now, read them as "exactly two numbers":
const point: [number, number] = [10, 20];
point.push(30);
const [x, y, z] = point;
One or more lines in this code may error. Which ones? Adding a third number to a structure defined for two numbers, or trying to read three values out of it? The answer comes in two halves: one in the section on tuples, the other in the section on readonly tuples.
Array Types: string[] and Array<string>
We worked with arrays a lot in the JavaScript series. In TypeScript there are two ways to write an array's type, and both mean exactly the same thing:
const tags: string[] = ["typescript", "javascript"];
const ids: Array<number> = [1, 2, 3];
// Error: Argument of type 'number' is not assignable to parameter of type 'string'.
tags.push(42);
string[] means "an array made of strings." Array<number> is the same thing written with generic syntax; the type inside the angle brackets is the type of the array's elements. We'll cover generics in detail in the Intermediate series. For now, pick one of the two and use it consistently in your project; in this post we'll use the shorter string[] form.
Once the array's type is known, TypeScript checks every operation against it. That's why tags.push(42) is rejected. The result is the same without the annotation; as you'll remember from the previous posts, TypeScript already infers string[] from the value ["typescript", "javascript"]. You really need the annotation when you start with an empty array, or in a function parameter.
Readonly Arrays: Readable but Unchangeable
Sometimes you want an array never to be changed. For that you put readonly in front of the type:
const weekdays: readonly string[] = ["Mon", "Tue", "Wed"];
// Error: Property 'push' does not exist on type 'readonly string[]'.
weekdays.push("Thu");
// Error: Index signature in type 'readonly string[]' only permits reading.
weekdays[0] = "Sun";
The readonly string[] type has none of the methods that change the array in place, such as push, pop and sort, and you can't assign by index either. Methods that return a new array, such as map, filter and slice, are still there. You can also write the same type as ReadonlyArray<string>.
Readonly arrays are most useful in function parameters. When you mark a parameter readonly, you make a promise to the caller: "I won't touch your array."
function printAll(items: readonly string[]) {
for (const item of items) {
console.log(item);
}
}
function sortInPlace(items: string[]) {
items.sort();
}
const languages = ["typescript", "go"];
const frozen: readonly string[] = ["rust", "zig"];
printAll(languages);
printAll(frozen);
// Error: The type 'readonly string[]' is 'readonly' and cannot be assigned to the mutable type 'string[]'.
sortInPlace(frozen);
A normal array can be passed freely to something that expects a readonly one; handing a mutable thing to someone who promises not to change it carries no risk. The reverse is an error: you can't pass an array that mustn't change to a function that will sort it in place. One thing to remember: readonly exists only at compile time. If you want to really freeze the array at runtime, you need Object.freeze, which you know from the JavaScript series.
Tuples: Each Position Has Its Own Type
In an array, all elements share the same type and their count isn't fixed. Sometimes you want the exact opposite: a structure with a fixed length and a different type at each position. That's called a tuple:
const entry: [string, number] = ["Ada", 36];
entry[0].toUpperCase();
entry[1].toFixed(1);
// Error: Tuple type '[string, number]' of length '2' has no element at index '2'.
const extra = entry[2];
// Error: the type doesn't match in either position (TS2322).
const swapped: [string, number] = [36, "Ada"];
TypeScript knows that entry[0] is a string and entry[1] is a number, and it also knows there's no third position. That's exactly why the const [x, y, z] = point line from the opening errors:
error TS2493: Tuple type '[number, number]' of length '2' has no element at index '2'.
Tuples are especially handy for returning more than one value from a function:
function getMinMax(values: number[]): [number, number] {
return [Math.min(...values), Math.max(...values)];
}
const [low, high] = getMinMax([4, 8, 15, 16, 23, 42]);
console.log(low, high); // 4 42
We have to write the return type here, because TypeScript doesn't infer a tuple from an array literal on its own. Write const pair = ["Ada", 36] and the type is not [string, number] but (string | number)[]: an array of any length where every position may hold a string or a number. If you want a tuple, you say so with an annotation.
Optional and Rest Elements
A tuple's length doesn't always have to be a single number. Put a ? after an element and that element becomes optional:
const release: [number, number, number?] = [7, 0];
const patchRelease: [number, number, number?] = [7, 0, 2];
console.log(release.length, patchRelease.length); // 2 3
const patch = release[2];
// const patch: number | undefined
The type of an optional element automatically includes undefined; you need to check it before using it. The type of the length property is no longer a single number either, but 2 | 3.
With a rest element you can say "fixed positions first, then as many of this type as you like":
const scoreRow: [string, ...number[]] = ["Ada", 90, 85, 70];
const nameOnly: [string, ...number[]] = ["Ada"];
// Error: Type 'number' is not assignable to type 'string'.
const noName: [string, ...number[]] = [90, 85];
The first element is always a string, followed by zero or more numbers. It's a perfect fit for structures like a table row, or a command's name followed by its arguments.
Named Tuples: Giving Positions a Name
The type [number, number] doesn't tell you what's what. Which is the minimum and which is the maximum? Named tuples, which arrived in TypeScript 4.0, solve this:
function getRange(values: number[]): [min: number, max: number] {
return [Math.min(...values), Math.max(...values)];
}
const range = getRange([3, 9, 1]);
console.log(range[0], range[1]); // 1 9
The names here are purely for readability; they don't change the type and have no counterpart at runtime. No range.min property is created; you still reach the values by index or destructuring. But when you hover over getRange in the editor you see the return type as [min: number, max: number], and whoever uses the function doesn't have to guess what sits where. Names work with optional elements too: [major: number, minor: number, patch?: number].
Readonly Tuples and the Other Half of the Answer
Back to the first question from the opening: does the point.push(30) line error? Surprisingly, it doesn't. In TypeScript, tuples are a special kind of array, and a normal tuple carries all of an array's methods, push included. When the code runs, point really does contain three numbers, while the type still says it holds two. This is one of the type system's known small holes.
The way to close the hole is to make the tuple readonly:
const point: readonly [number, number] = [10, 20];
// Error: Property 'push' does not exist on type 'readonly [number, number]'.
point.push(30);
Marking tuples whose values won't change later as readonly, especially small groups of values like coordinates or ranges, is a good habit. You're protected from this hole, and you tell the reader "this structure is fixed."
A Quick Introduction to noUncheckedIndexedAccess
This code compiles without complaint in strict mode:
const names = ["Ada", "Grace"];
console.log(names[5].toUpperCase());
But when it runs, it fails with "Cannot read properties of undefined (reading 'toUpperCase')." Here's why: TypeScript assumes that every index of an array of type string[] returns a string. It can't know at compile time how many elements the array has.
The noUncheckedIndexedAccess setting, which is on in the tsconfig that tsc --init generates, changes that assumption. With the setting on, the type of names[5] becomes string | undefined, and the same line gives this error:
error TS2532: Object is possibly 'undefined'.
This setting isn't part of strict; once you turn it on, you have to check every read you make by index. That has a cost, but it catches exactly the kind of bug above. Fixed-length tuples aren't affected: entry[0] is still a plain string, because TypeScript knows that position exists. The for...of loop isn't affected either. We'll come back to the details of this setting in the tsconfig post.
Common Pitfalls
The first is the parentheses issue. If you want to write "an array holding strings or numbers" and type string | number[], you've actually said "either a string or an array of numbers." The correct form is (string | number)[]. If you're unsure, write Array<string | number>; there's no ambiguity there.
The second is getting an array when you expected a tuple. A ["Ada", 36] you create without an annotation isn't a tuple. If you return a tuple from a function, write the return type.
The third is believing readonly does something at runtime. It only limits the operations TypeScript lets you perform. If there's an any somewhere in the code, or the data comes from somewhere TypeScript can't see, the array can still be changed.
A Small Experiment
★☆☆ Does it compile? const scores: number[] = [90, 85]; scores.push("100"); And how does the error message change when you make the type of scores readonly number[]?
★★☆ Predict: what type does TypeScript infer for const user = ["Ada", 36];? Does the line user[0].toUpperCase() compile? Turn this variable into a tuple by adding an annotation and try the same line again.
★★☆ Write a named tuple representing an HTTP request: the first element is the method name (string), the second is the URL (string), and the third is an optional timeout (number). Create two sample values, one with two elements and one with three.
★★★ Call the getMinMax function with an empty array and print the result. What does the type say, and what comes out at runtime? How would you change the return type to close that gap?
With arrays and tuples we've typed ordered data. But in JavaScript most data isn't kept by position; it's kept by name: a user's name, email and age. In the next post we'll look at object types, optional and readonly properties, index signatures, and why TypeScript sometimes catches the extra properties you write and sometimes doesn't.