LEVEL 5 · LESSON 13 OF 21

Special types and narrowing Foundation

Tuples

Type fixed-shape arrays with tuples - optional, rest and named elements, readonly tuples, tuple inference and const type parameters - and return tuples from functions the way useState does.

  • 40 min to read and try
  • You need: Narrowing and Enums and their alternatives
  • You build: A typed bank-statement parser that turns CSV lines into tuples, plus a useState-style state helper
Test yourself

BY THE END OF THIS LESSON YOU CAN

  • Tell when an array literal is inferred as an array and when as a tuple
  • Write tuples with optional, rest and named elements
  • Protect tuples from push and in-place sorting with readonly
  • Return tuples from functions, useState-style, and destructure them
  • Model success and failure as a union of tuples and narrow it

A row that lost its shape

Your bank lets customers download a statement as CSV. Each line has a date, a description and an amount in naira. You parse a line and want to add up the amounts:

row.ts
const row = ["2026-09-01", "POS purchase, Shoprite Lekki", -2500];

const amount = row[2];
console.log(amount.toFixed(2));
What npx tsc --noEmit prints
row.ts:4:20 - error TS2339: Property 'toFixed' does not exist on type 'string | number'.
  Property 'toFixed' does not exist on type 'string'.

4 console.log(amount.toFixed(2));
                     ~~~~~~~


Found 1 error in row.ts:4

You can see that position 2 holds a number. TypeScript cannot, because it inferred row as (string | number)[]: an array of any length where every element could be a string or a number. It has no idea which position holds which. For a list of similar things (a list of prices, a list of names), that is exactly right. For a small, fixed record where each position means something different, you need a tuple: an array type with a fixed number of elements, each with its own type.

row.ts
const row: [string, string, number] = ["2026-09-01", "POS purchase, Shoprite Lekki", -2500];

const [date, description, amount] = row;
console.log(date, amount.toFixed(2), description.length);
Output of npx tsx row.ts and of the browser terminal
2026-09-01 -2500.00 28

You met tuples briefly in Basic types. This lesson covers the whole feature: optional and rest elements, labels, readonly, how TypeScript decides between an array and a tuple, and the places where tuples are the best design, and where they are not.

What a tuple type knows

A tuple type carries three pieces of information an array type does not: how many elements there are, the type of each position, and therefore which indexes exist:

knows.ts
const transfer: [from: string, to: string, kobo: number] = ["ACC-001", "ACC-002", 250000];

const size: 3 = transfer.length;
const [from, to, kobo] = transfer;
console.log(`${from} -> ${to}: ₦${(kobo / 100).toLocaleString("en-NG")}`, size);
Output of npx tsx knows.ts and of the browser terminal
ACC-001 -> ACC-002: ₦2,500 3

transfer.length has the literal type 3, not number. Reading or destructuring past the end is a compile error:

past-end.ts
const transfer: [string, string, number] = ["ACC-001", "ACC-002", 250000];

const [from, to, kobo, fee] = transfer;
console.log(transfer[3]);
What npx tsc --noEmit prints
past-end.ts:3:24 - error TS2493: Tuple type '[string, string, number]' of length '3' has no element at index '3'.

3 const [from, to, kobo, fee] = transfer;
                         ~~~

past-end.ts:4:22 - error TS2493: Tuple type '[string, string, number]' of length '3' has no element at index '3'.

4 console.log(transfer[3]);
                       ~


Found 2 errors in the same file, starting at: past-end.ts:3

Under the hood a tuple is an ordinary JavaScript array. Nothing at runtime knows it is a tuple, which matters in a moment.

Named elements

In [from: string, to: string, kobo: number], the names before the colons are labels (TypeScript calls them named tuple elements). They change nothing about the type: [from: string] and [string] are the same type, and the caller can destructure with any names. They exist for people: your editor shows them on hover and in error messages, and they document what each position means. Either label every element or none.

Optional and rest elements

Optional elements

A ? after an element type makes that element optional. Optional elements must come after all required ones, and the length type becomes a union:

optional.ts
type Fee = [label: string, kobo: number, waivedFor?: string];

const fees: Fee[] = [
  ["SMS alert", 400],
  ["Transfer fee", 1075, "Premium"],
];

for (const [label, kobo, waivedFor] of fees) {
  const note = waivedFor === undefined ? "" : ` (free for ${waivedFor})`;
  console.log(`${label}: ₦${kobo / 100}${note}`);
}

const first: Fee = ["Card maintenance", 5000];
const size: 2 | 3 = first.length;
console.log(size);
Output of npx tsx optional.ts and of the browser terminal
SMS alert: ₦4
Transfer fee: ₦10.75 (free for Premium)
2

Inside the loop, waivedFor is string | undefined, so you must narrow it before use, exactly as with an optional property.

Rest elements

A rest element, ...T[], stands for zero or more elements of one type. It turns a tuple into a type with a fixed part and a variable part. A monthly report row with a branch name followed by any number of daily totals:

rest.ts
type BranchRow = [branch: string, ...dailyKobo: number[]];

const rows: BranchRow[] = [
  ["Lekki", 1250000, 980000, 1430000],
  ["Ikeja", 2100000],
  ["Wuse"],
];

for (const [branch, ...daily] of rows) {
  const total = daily.reduce((sum, kobo) => sum + kobo, 0);
  console.log(`${branch}: ₦${(total / 100).toLocaleString("en-NG")}, days reported: ${daily.length}`);
}
Output of npx tsx rest.ts and of the browser terminal
Lekki: ₦36,600, days reported: 3
Ikeja: ₦21,000, days reported: 1
Wuse: ₦0, days reported: 0

The rest element can also sit at the start or in the middle, as long as there is only one. [...path: string[], amount: number] describes "any number of strings, then exactly one number at the end", which fits a ledger path like ["assets", "bank", "gtbank", 500000]:

leading-rest.ts
type LedgerEntry = [...path: string[], kobo: number];

function describe(entry: LedgerEntry): string {
  const kobo = entry[entry.length - 1];
  const path = entry.slice(0, -1);
  return `${path.join(":")} = ${kobo}`;
}

console.log(describe(["assets", "bank", "gtbank", 500000]));
console.log(describe(["expenses", 2500]));
Output of npx tsx leading-rest.ts and of the browser terminal
assets:bank:gtbank = 500000
expenses = 2500

TypeScript knows only the shape here, not every index: entry[entry.length - 1] is typed string | number, and the template literal accepts both. When a type starts needing this much care, an object like { path: string[]; kobo: number } is often clearer; you will see that trade-off again at the end of the lesson.

readonly tuples

Here is the runtime fact from earlier coming back to bite. A tuple is a real array, and plain tuple types still allow the array methods that change length:

push.ts
const rate: [currency: string, nairaPerUnit: number] = ["USD", 1550];

rate.push(1600);

const [currency, value] = rate;
console.log(currency, value, rate.length, rate);
Output of npx tsx push.ts and of the browser terminal
USD 1550 3 [ 'USD', 1550, 1600 ]

This compiles. The type still says "two elements" while the array now has three. A readonly tuple removes push, pop, sort, splice and assignment to elements, so the shape cannot drift:

readonly.ts
const rate: readonly [currency: string, nairaPerUnit: number] = ["USD", 1550];

rate.push(1600);
rate[1] = 1600;
What npx tsc --noEmit prints
readonly.ts:3:6 - error TS2339: Property 'push' does not exist on type 'readonly [currency: string, nairaPerUnit: number]'.

3 rate.push(1600);
       ~~~~

readonly.ts:4:6 - error TS2540: Cannot assign to '1' because it is a read-only property.

4 rate[1] = 1600;
       ~


Found 2 errors in the same file, starting at: readonly.ts:3

Write readonly on tuples you do not intend to change, which is almost all of them. A function parameter typed readonly [string, number] accepts both mutable and readonly tuples, so it is the friendlier choice for inputs too. To "change" a readonly tuple, make a new one: const next = [rate[0], 1600] as const.

Sorting in place

sort and reverse change the array they are called on. On a tuple that holds different kinds of things, that can scramble the positions. On a readonly tuple, TypeScript refuses; use the copying versions toSorted and toReversed instead, which return a new array:

to-sorted.ts
const topUps: readonly [number, number, number] = [5000, 1000, 20000];

const ascending = topUps.toSorted((a, b) => a - b);
console.log(ascending, topUps);
Output of npx tsx to-sorted.ts and of the browser terminal
[ 1000, 5000, 20000 ] [ 5000, 1000, 20000 ]

Note the result type: toSorted returns number[], not a tuple. After sorting, TypeScript no longer promises which value is where, which is honest.

When TypeScript infers a tuple

An array literal on its own is always inferred as an array. That is a deliberate default: most arrays grow and shrink. There are four ways to get a tuple instead.

inference.ts
const inferred = ["NGN", 1];
const annotated: [string, number] = ["NGN", 1];
const constant = ["NGN", 1] as const;

function pair(): [currency: string, rate: number] {
  return ["USD", 1550];
}
const returned = pair();

function tuple<const T extends readonly unknown[]>(...items: T): T {
  return items;
}
const generic = tuple("GBP", 2050, true);

console.log(inferred.length, annotated.length, constant.length, returned.length, generic.length);
Output of npx tsx inference.ts and of the browser terminal
2 2 2 2 3
  • inferred is (string | number)[]: an array.
  • An annotation makes it [string, number].
  • as const makes it readonly ["NGN", 1]: a readonly tuple of literal types. That is the most precise type there is, and often too precise for values that are not constants.
  • A return type on a function makes the returned literal a tuple, which is how most tuples in real code are created.
  • A const type parameter, <const T extends readonly unknown[]> (TypeScript 5.0), asks TypeScript to infer T as if the caller had written as const. Here generic is readonly ["GBP", 2050, true], without the caller writing anything.

Without the return type, even a two-element return would be inferred as an array, and every caller would lose the positions:

no-return-type.ts
function exchangeRate(currency: string) {
  return [currency, 1550];
}

const [code, rate] = exchangeRate("USD");
console.log(rate.toFixed(2));
What npx tsc --noEmit prints
no-return-type.ts:6:18 - error TS2339: Property 'toFixed' does not exist on type 'string | number'.
  Property 'toFixed' does not exist on type 'string'.

6 console.log(rate.toFixed(2));
                   ~~~~~~~


Found 1 error in no-return-type.ts:6

Returning tuples, useState-style

The most famous tuple in JavaScript is React's const [count, setCount] = useState(0). It returns a tuple, not an object, for one reason: the caller chooses the names. With an object, every caller would have to write const { value: count, setValue: setCount } = …. With a tuple, names are free and destructuring is short. Here is the same idea as a small state helper for a shopping cart:

state.ts
export function createState<T>(initial: T): readonly [get: () => T, set: (next: T | ((previous: T) => T)) => void] {
  let value = initial;
  const get = () => value;
  const set = (next: T | ((previous: T) => T)) => {
    value = typeof next === "function" ? (next as (previous: T) => T)(value) : next;
  };
  return [get, set] as const;
}

const [itemCount, setItemCount] = createState(0);
const [coupon, setCoupon] = createState<string | null>(null);

setItemCount(3);
setItemCount((n) => n + 1);
setCoupon("SAVE10");

console.log(itemCount(), coupon());
Output of npx tsx state.ts and of the browser terminal
4 SAVE10
  • The return type is a readonly tuple with labels, so callers see get and set in their editor, then pick their own names.
  • T is inferred from the initial value: number for the counter. For the coupon, null alone would infer T = null, so the caller passes <string | null>, exactly as with useState.
  • The one as is there because TypeScript cannot tell, from typeof next === "function", that the function is the updater and not a T that happens to be a function. It is the "proof the compiler cannot follow" case from Type assertions; it would be wrong if T itself were a function type, which is why React documents the same limitation.

Tuples you already use

The standard library returns and accepts tuples in several places, and the types are written that way:

builtins.ts
const balances = { "ACC-001": 125000, "ACC-002": 0 };

for (const [account, kobo] of Object.entries(balances)) {
  console.log(account, kobo);
}

const fees = new Map<string, number>([["transfer", 1075], ["sms", 400]]);
console.log([...fees.entries()][0]);

const [user, account] = await Promise.all([
  Promise.resolve({ name: "Ada" }),
  Promise.resolve({ id: "ACC-001", kobo: 125000 }),
]);
console.log(user.name, account.kobo);
Output of npx tsx builtins.ts and of the browser terminal
ACC-001 125000
ACC-002 0
[ 'transfer', 1075 ]
Ada 125000

Object.entries gives [string, number][], an array of pairs. A Map is built from pairs. Promise.all on a tuple of promises returns a tuple of results, so user and account each get their own type; with a plain array, both would be a union.

Tuples as parameter lists

A function's parameter list is itself a tuple type, and a rest parameter can be typed with one. Parameters<typeof fn> from Advanced and utility types gives you that tuple:

params.ts
function transfer(from: string, to: string, kobo: number): string {
  return `${from} -> ${to}: ${kobo}`;
}

type TransferArgs = Parameters<typeof transfer>;

const queued: TransferArgs[] = [
  ["ACC-001", "ACC-002", 250000],
  ["ACC-003", "ACC-001", 1000],
];

for (const args of queued) console.log(transfer(...args));
Output of npx tsx params.ts and of the browser terminal
ACC-001 -> ACC-002: 250000
ACC-003 -> ACC-001: 1000

TransferArgs is [from: string, to: string, kobo: number], labels included. Spreading a tuple into a call type-checks each argument by position, which is what makes a queue of saved calls safe.

Build: a statement parser

REASON IT OUT

Before you parse the statement

Each line of the CSV looks like 2026-09-01,POS purchase,-2500.00. Some lines have a fourth column, a reference. The file comes from another system, so treat it as outside data. Think first:

  • What tuple type describes a valid row, including the optional reference?
  • What can be wrong with a line? Think about too few columns, a date that is not a date, an amount that is not a number, and a description that contains a comma.
  • How should the parser report a bad line: throw, skip it silently, or return something the caller must check?
  • Amounts like -2500.00 are naira with decimals. What should the program store?
Show the reasoning

A valid row is readonly [date: string, description: string, kobo: number, reference?: string]. A line can have too few or too many columns, a malformed date, or an amount where Number(…) gives NaN. A real bank CSV quotes descriptions that contain commas; this simple parser does not support quotes, so it must at least refuse lines with the wrong number of columns instead of shifting every field. Skipping silently hides data loss and throwing stops the whole file for one bad line, so each line returns either an error or a row, and the caller decides. A union of two tuples, [error: string, row: null] | [error: null, row: Row], does exactly that and narrows on the first element. Money is stored as whole kobo, as in Narrowing.

statement.ts
export type Row = readonly [date: string, description: string, kobo: number, reference?: string];
export type ParseResult = readonly [error: string, row: null] | readonly [error: null, row: Row];

export function parseLine(line: string): ParseResult {
  const cells = line.split(",").map((cell) => cell.trim());
  if (cells.length < 3 || cells.length > 4) return [`expected 3 or 4 columns, got ${cells.length}`, null];
  const [date, description, amount, reference] = cells;
  if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return [`bad date "${date}"`, null];
  const naira = Number(amount);
  if (amount === "" || !Number.isFinite(naira)) return [`bad amount "${amount}"`, null];
  const kobo = Math.round(naira * 100);
  return [null, reference ? [date, description, kobo, reference] : [date, description, kobo]];
}

The parser lives in its own module, so the tests can import it. A small script runs it over a sample statement:

main.ts
import { parseLine } from "./statement.js";

const csv = `2026-09-01,POS purchase,-2500.00
2026-09-02,Salary,450000.00,PAY-0925
2026-09-03,Airtime
2026-09-04,Transfer to Ada,abc
2026-09-05,Lunch, Yaba,-3000`;

let balance = 0;
for (const [lineNo, line] of csv.split("\n").entries()) {
  const [error, row] = parseLine(line);
  if (error !== null) {
    console.log(`line ${lineNo + 1}: ${error}`);
    continue;
  }
  const [date, description, kobo, reference] = row;
  balance += kobo;
  console.log(`${date} ${description.padEnd(16)} ${String(kobo).padStart(9)}${reference ? " " + reference : ""}`);
}
console.log(`balance: ₦${(balance / 100).toFixed(2)}`);
Output of npx tsx main.ts and of the browser terminal
2026-09-01 POS purchase       -250000
2026-09-02 Salary            45000000 PAY-0925
line 3: expected 3 or 4 columns, got 2
line 4: bad amount "abc"
line 5: bad amount "Yaba"
balance: ₦447500.00

Read how the tuples work together:

  • ParseResult is a union of two tuples, and the first element is string in one and null in the other. null is a unit type, so it works as a discriminant: after if (error !== null) continue, TypeScript knows row is a Row, even though both came from destructuring.
  • cells is a string[], not a tuple, so destructuring it gives four strings. That is not quite true: on a three-column line, reference is undefined. The truthiness check reference ? … : … handles it (an empty reference counts as none), but the compiler did not make you write it. The noUncheckedIndexedAccess option, covered in tsconfig in depth, makes every such element string | undefined so you cannot forget.
  • The last line has a comma inside the description and produced four columns: "Yaba" became the amount and was refused. Without the column count and amount checks, it would have been stored as a real transaction. Supporting quoted CSV properly is a job for a CSV library.

Testing functions that return tuples

A tuple is an array, so tests compare it with deep equality, position by position. Test each position you care about, the failure tuples, and the optional element both present and absent:

statement.test.ts
import { parseLine } from "./statement.js";

const cases: [line: string, expected: unknown][] = [
  ["2026-09-01,POS purchase,-2500.00", [null, ["2026-09-01", "POS purchase", -250000]]],
  ["2026-09-02,Salary,450000,PAY-1", [null, ["2026-09-02", "Salary", 45000000, "PAY-1"]]],
  ["2026-09-03,Airtime", ["expected 3 or 4 columns, got 2", null]],
  ["01/09/2026,Rent,-100000", ['bad date "01/09/2026"', null]],
  ["2026-09-04,Refund,", ['bad amount ""', null]],
];

for (const [line, expected] of cases) {
  const same = JSON.stringify(parseLine(line)) === JSON.stringify(expected);
  console.log(same ? "PASS" : "FAIL", line);
}
Output of npx tsx statement.test.ts and of the browser terminal
PASS 2026-09-01,POS purchase,-2500.00
PASS 2026-09-02,Salary,450000,PAY-1
PASS 2026-09-03,Airtime
PASS 01/09/2026,Rent,-100000
PASS 2026-09-04,Refund,

Notice "2026-09-04,Refund,": Number("") is 0, not NaN, so without the explicit amount === "" check an empty amount would have become a ₦0 transaction. The test table is where you find out. In a Vitest suite, expect(parseLine(line)).toEqual(expected) does the deep comparison for you.

Tuples in production code

  • Keep them short. Two or three positions whose meaning is obvious: pairs, entries, [value, setValue], [error, result]. Beyond that, use an object; row.kobo is clearer than row[2], and adding a field does not shift every position.
  • Make them readonly. push on a tuple compiles and breaks the type's promise. readonly costs nothing.
  • Label them. Labels are free documentation that shows up in editors and error messages.
  • Validate at the boundary. JSON has no tuples: JSON.parse("[1, 2]") is an array of any. A tuple from a request, file or queue needs its length and every position checked, like any outside data.
  • Prefer objects in public APIs and JSON. An API field "rate": ["USD", 1550] forces every client to know the positions; { "currency": "USD", "rate": 1550 } explains itself.

Practice

TRY IT YOURSELF

Min and max in one pass

Write range(amounts: readonly number[]): readonly [min: number, max: number] | null, which returns null for an empty list. Use it on a list of withdrawals.

Show a solution
range.ts
function range(amounts: readonly number[]): readonly [min: number, max: number] | null {
  const [first, ...rest] = amounts;
  if (first === undefined) return null;
  let min = first;
  let max = first;
  for (const amount of rest) {
    if (amount < min) min = amount;
    if (amount > max) max = amount;
  }
  return [min, max];
}

const result = range([5000, 1200, 20000, 800]);
if (result !== null) {
  const [smallest, largest] = result;
  console.log(smallest, largest);
}
console.log(range([]));
Output of npx tsx range.ts and of the browser terminal
800 20000
null

The return type makes the returned literal a tuple, and the caller chooses the names. null for an empty list is part of the type, so the caller must narrow before destructuring.

TRY IT YOURSELF

A useToggle helper

Using the same idea as createState, write useToggle(initial: boolean) that returns a readonly tuple [isOn: () => boolean, toggle: () => void]. Use it for a "show balance" switch.

Show a solution
toggle.ts
function useToggle(initial: boolean): readonly [isOn: () => boolean, toggle: () => void] {
  let on = initial;
  return [() => on, () => { on = !on; }] as const;
}

const [showBalance, toggleBalance] = useToggle(false);
console.log(showBalance());
toggleBalance();
console.log(showBalance());
toggleBalance();
console.log(showBalance());
Output of npx tsx toggle.ts and of the browser terminal
false
true
false

Both functions close over the same on variable, so the toggle is visible through the getter. The caller named them showBalance and toggleBalance, which an object return would not have allowed without renaming.

TRY IT YOURSELF

Tuple or object?

For each of these, would you use a tuple or an object, and why? (a) A currency pair and its rate returned by latestRate(). (b) A customer record with name, e-mail, phone, BVN and date of birth. (c) The two halves of a split bill, [yours, mine].

Show a solution

(a) Either works; a labelled readonly tuple [pair: string, rate: number] is fine for a two-value return that callers destructure at once, and an object is better if it goes into JSON. (b) An object: five fields, all strings except the date, would be impossible to keep in the right order, and positions say nothing in logs or JSON. (c) A tuple: two values of the same kind, destructured immediately, where the caller naming them (const [yours, mine] = split(bill)) is the whole point.

Recap

  • A tuple type fixes the length and the type of each position. An array literal is inferred as an array unless an annotation, a return type, as const or a const type parameter says otherwise.
  • ? marks optional elements (at the end), ...T[] a rest element (one per tuple, anywhere). Labels document positions and change nothing else.
  • Plain tuples still allow push and sort. readonly tuples do not; use toSorted for a sorted copy.
  • Return tuples when callers should name the parts ([get, set], [error, row]). Object.entries, Map, Promise.all and Parameters all speak tuples.
  • A union of tuples with a null or literal element narrows like a discriminated union, even after destructuring.
  • Keep tuples short, readonly and labelled, validate them at the boundary, and switch to an object when positions stop being obvious.

Next: Generics, where functions like createState<T> come from.

Test yourself

Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.