An array holds any number of one kind of thing. A tuple holds a fixed number of things whose types depend on their position. JavaScript uses the same square brackets for both, which is why TypeScript has to be told which you meant.
Two ways to write an array type
let sizes: ProductSize[];
let sizesAgain: Array<ProductSize>;They are identical — the same type, no difference in behaviour. Pick one and be consistent. The
common convention, and the one the pizza app follows, is T[] for simple element types:
export interface Product {
// ...
sizes: ProductSize[];
createdAt: string;
updatedAt: string;
}and Array<T> when the element type is written inline and would make the
brackets hard to find:
export interface ReportDashboard {
summary: {
totalOrders: number;
totalRevenue: number;
averageOrderValue: number;
itemsSold: number;
};
revenueByDay: Array<{ day: string; orders: number; revenue: number }>;
topProducts: Array<{ productName: string; unitsSold: number; revenue: number }>;
statusBreakdown: Array<{ status: string; count: number }>;
}Written the other way that last line is { status: string; count: number }[], where
the [] is easy to miss at the end of a long line. That is the whole argument for the
generic form, and it is a readability preference rather than a rule.
Where the brackets go matters
One genuine trap. These two are different types:
let a: (string | null)[]; // an array, whose items may be null
let b: string[] | null; // either an array of strings, or nullWithout the parentheses, string | null[] parses as the second — []
binds tighter than |. If you mean an array of nullable things, parenthesise.
This shows up constantly with the app's nullable fields. imageUrl: string | null
is one value; a list of them would be (string | null)[].
Inference and the empty array
TypeScript infers array types from their contents, and does the sensible thing with a mixed list:
const names = ['Cheese', 'Pepperoni']; // string[]
const mixed = ['Cheese', 3]; // (string | number)[]The empty array is the exception, and it is worth knowing why it behaves oddly:
const found = []; // any[]
found.push('Cheese'); // now string[]
found.push(42); // now (string | number)[]An empty literal starts as an evolving any[] and takes its type from what
you push, within the same scope. Useful in a loop; a problem when the array escapes to somewhere
that push cannot be seen. Annotate it when you know:
const errors: string[] = [];readonly arrays
Prefix an array type with readonly and mutating methods disappear from it:
// This does not compile.
function total(items: readonly CartItem[]) {
items.sort((a, b) => a.quantity - b.quantity);
// ~~~~
// Property 'sort' does not exist on type 'readonly CartItem[]'.
}That error is a good one to have caught. sort mutates in place, so a "harmless"
sort inside a display helper reorders the caller's cart. readonly on the parameter
makes that a compile error rather than a bug report about items jumping around.
The same type can be written ReadonlyArray<CartItem>. Note that it is
shallow: the array cannot be changed, but the objects in it can.
Also note this is a compile-time restriction. A readonly string[] is an
ordinary array at runtime with all its methods intact — nothing is frozen. readonly
constrains your code, not the value.
Types flow through the array methods
You mostly do not annotate anything when working with arrays, because map,
filter and reduce carry the element type through for you:
export function calculateTotals(items: CartItem[], orderType: 'DELIVERY' | 'CARRYOUT'): CartTotals {
const subtotal = round2(items.reduce((sum, item) => sum + lineTotal(item), 0));item is a CartItem and sum is a number,
neither written down. reduce's type says the accumulator matches the initial value —
the 0 — and the callback receives the array's element type.
map changes the element type as you would hope:
const prices = product.sizes.map((s) => s.price); // number[]
const labels = product.sizes.map((s) => s.size); // SizeName[]There is one place this breaks down, and it catches everyone.
filter does not narrow
Removing the nulls from a list does not change its type:
// This does not compile.
const urls: (string | null)[] = products.map((p) => p.imageUrl);
const real: string[] = urls.filter((u) => u !== null);
// ~~~~
// Type '(string | null)[]' is not assignable to type 'string[]'.You know the result has no nulls in it. TypeScript does not, because filter is
typed to return an array of the same element type — it has no way to interpret an arbitrary
predicate.
The fix is a type predicate, a return type of the form x is T that
tells the compiler what a true result means:
const real = urls.filter((u): u is string => u !== null); // string[]The (u): u is string is doing all the work. Narrowing
covers predicates properly; for now, know that this is why filter(Boolean) almost
never gives you the type you wanted.
Indexing is more optimistic than it should be
By default, reading any index gives you the element type, with no acknowledgement that the array might be shorter than you think:
const sizes: ProductSize[] = product.sizes;
const first = sizes[0]; // ProductSize — even if the array is empty
first.price.toFixed(2); // compiles. Throws at runtime on an empty array.That is a real hole in an otherwise strict type system, and it is not covered by
strict. The flag that closes it is noUncheckedIndexedAccess, which makes
every indexed read ProductSize | undefined and forces you to handle the empty case.
It is off by default, and it is genuinely noisy — every loop body that indexes an array needs a
check. Worth turning on for new code; expect a long afternoon if you enable it on an existing
project. at(), find() and destructuring all return possibly-undefined
values regardless, so much of your code already handles this.
Tuples
A tuple is an array type where the length is fixed and each position has its own type:
let entry: [string, number];
entry = ['PAID', 12]; // fine
entry = [12, 'PAID']; // wrong order — error
entry = ['PAID', 12, 3]; // too long — errorCompare that with what you get without the tuple type. An array literal of two different things
infers as (string | number)[], which loses everything useful: the length, which
position is which, and the ability to read entry[0] as a string.
Tuples are best when the elements have no good names — a coordinate pair, a key/value entry, a returned pair. The moment a name would help, an object is clearer.
Named members, optional and rest
Tuple members can be labelled. The labels are documentation only, but they show up in editor hints and error messages, which is enough to justify them:
type Range = [min: number, max: number];A tuple can have optional trailing members, and a rest element for a variable tail:
type Point = [x: number, y: number, z?: number];
type Command = [name: string, ...args: string[]];Command is "a string, then any number of strings" — the two are different types,
and only the first element is guaranteed.
The tuple you use every day
If you have written React, you have used tuples heavily without noticing:
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(false);useState returns a tuple: the value first, the setter second. That is exactly why
array destructuring works on it, and why you can name the two halves whatever you like — the
meaning is positional, not by key.
It also explains a small mystery. If useState returned
(User | null | Function)[], then setUser would have that whole union as
its type and calling it would not typecheck. The tuple is what keeps position 0 the value and
position 1 the setter.
Getting a tuple by accident, or on purpose
TypeScript will not infer a tuple unless you ask. This returns an array type:
function bounds() {
return [0, 100]; // number[], not [number, number]
}Two ways to get the tuple. Annotate the return type:
function bounds(): [number, number] {
return [0, 100];
}or use as const, which freezes the literal into
readonly [0, 100]:
const bounds = [0, 100] as const;Those give different things — the first is a mutable tuple of any two numbers, the second is a
readonly tuple of exactly those two values.
Lesson 16 covers as const
properly, and it is worth reading before you sprinkle it about.
Spreading and destructuring
Both work as you would expect, and both preserve types.
const cheapest = Math.min(...product.sizes.map((s) => s.price));Spreading a number[] into Math.min is allowed because
Math.min takes a rest parameter of numbers. Spread a tuple and TypeScript knows the
arity, so it can check the call against a fixed-parameter function too.
Destructuring an array gives you possibly-undefined values only under
noUncheckedIndexedAccess; a tuple always gives you the exact positional types:
const [min, max]: [number, number] = bounds(); // both number
const [head, ...tail] = ['a', 'b', 'c']; // head: string, tail: string[]Which to reach for
In application code, arrays almost always. Every collection in the pizza app's domain — sizes, toppings, order items, addresses — is an array of a named type, because each element is a thing with a name and fields.
Tuples earn their place in three situations: a function returning two values that have no natural container, a fixed-shape pair like a coordinate or a range, and the return type of a hook you want destructured positionally. Outside those, an object with named properties reads better and survives a fourth field being added.
Worth mentioning the neighbours. Set<T> and Map<K, V> are
both generic and both typed exactly as you would hope:
const selectedToppingIds = new Set<UUID>();
const byId = new Map<UUID, Product>();
byId.get('abc')?.name; // Product | undefined — get() admits it might missNote that Map.get returns V | undefined honestly, where array indexing
does not. If you are keeping a lookup table, a Map gives you better types than an
object with an index signature, and it does not confuse your keys with
Object.prototype.
One last habit worth forming: when a function only reads a list, type the parameter
readonly T[]. It costs nothing, it documents that the function does not reorder or
mutate what it was given, and it turns a whole category of action-at-a-distance bug into a compile
error. The cost is that callers holding a mutable array can still pass it, so there is no downside
at the boundary — assignability runs the permissive direction.
Next
Object Types — optional properties, readonly, index signatures, and the excess property check that rejects an extra key on a literal while letting the same object through in a variable.