TypeScript Array Types
A TypeScript array is a plain JavaScript array with one extra promise attached: every element has the same type. That promise is the whole feature, and it is also where every surprise comes from — because TypeScript keeps it at compile time only, and the runtime array underneath never heard about it.
This page covers the syntax you will actually type, the inference rules that decide what you get when you do not annotate, and the two defaults that quietly lie to you.
Two ways to write a TypeScript array type
There are exactly two spellings, and they mean the same thing.
const shorthandNames: string[] = ['ada', 'grace']
const genericNames: Array<string> = ['ada', 'grace']string[] is shorthand for Array<string>. Not "mostly the same" — the compiler expands one
into the other, so you can assign either to the other and no tool can tell them apart afterwards.
Pick one and stay consistent. Most codebases default to T[] because it is shorter, and reach
for Array<T> only when the element type is long enough that the trailing [] gets lost:
type ApiHandler = (path: string) => Promise<Response>
// ❌ Easy to misread — is this an array of handlers, or a handler returning an array?
const terseHandlers: ((path: string) => Promise<Response>)[] = []
// ✅ The brackets cannot drift away from what they apply to
const clearHandlers: Array<ApiHandler> = []One place the choice is made for you: readonly only works with the shorthand, which we get to
below.
Arrays of unions vs unions of arrays
These two look similar and behave nothing alike.
const mixedItems: (string | number)[] = ['a', 1, 'b'] // one array, elements may be either
const eitherList: string[] | number[] = ['a', 'b'] // either an all-string or an all-number array(string | number)[] lets you mix. string[] | number[] does not — it is a choice between two
homogeneous arrays, decided once for the whole value.
The second form is stricter and usually what you want when the array comes from somewhere that produces one kind of thing at a time — a database column, a parsed CSV field, one branch of an API response. It encodes a rule the mixed form cannot: no row holds both kinds at once.
The cost shows up when you iterate. TypeScript will not let you call a method on the union until
you narrow it, because the two signatures do not merge into one callable type. A map over
string[] | number[] has to pick a callback parameter type, and there is no single honest answer
before you know which branch you are in.
function describeList(input: string[] | number[]): number {
// input.map(…) is awkward here: the two map overloads do not merge
return Array.isArray(input) ? input.length : 0
}Parentheses matter more than they look. readonly string[] | number[] makes only the first branch
readonly. If you want both, parenthesise.
readonly arrays
readonly on an array type removes every mutating method — push, pop, splice, sort,
index assignment. What is left is the read half of the API.
const frozenTags: readonly string[] = ['ts', 'js']
// frozenTags.push('mdx') // Property 'push' does not exist on type 'readonly string[]'
// frozenTags[0] = 'other' // Index signature in type 'readonly string[]' only permits reading
const grownTags = [...frozenTags, 'mdx'] // ✅ string[], a new arrayReadonlyArray<string> is the same type written long-hand. The modifier keyword has no generic
equivalent, so readonly Array<string> is not valid syntax — this is the one case where the two
spellings from earlier are not interchangeable.
Assignability runs one way. A mutable array goes where a readonly one is expected, never the reverse:
function countItems(items: readonly string[]): number {
return items.length
}
const mutableTags: string[] = ['a', 'b']
countItems(mutableTags) // ✅ string[] is assignable to readonly string[]
const readonlyTags: readonly string[] = ['a', 'b']
const copiedTags: string[] = [...readonlyTags] // spread to get a mutable copyThat makes readonly string[] the better default for function parameters. You accept strictly
more callers and you promise not to mutate their data, which is a promise most functions want to
make anyway.
Worth being clear about the limit: this is a type-level restriction only. readonly compiles to
nothing, and Object.freeze is the runtime version. Nothing stops a plain JavaScript caller from
pushing into your readonly array.
What TypeScript infers from a literal
Skip the annotation and the compiler guesses. The guess is a union of what it sees, widened to the general type:
const inferredNumbers = [1, 2, 3] // number[], not (1 | 2 | 3)[]
const inferredMixed = [1, 'two'] // (string | number)[]
const inferredNested = [[1], [2]] // number[][]Two cases are worth memorising.
The empty array starts as any[] and grows as you push, a behaviour called evolving array types.
It only works for a let or const declaration with no annotation, and it quietly ends the moment
the array leaves the scope:
const evolvingIds = [] // any[] here
evolvingIds.push(1)
evolvingIds.push(2) // number[] by this lineas const goes the other direction and freezes everything — literal element types plus a readonly
tuple instead of an array:
const httpMethods = ['GET', 'POST'] as const
// readonly ['GET', 'POST']
type HttpMethod = (typeof httpMethods)[number] // 'GET' | 'POST'That last line is the reason as const shows up so often: it turns a runtime list into a union
type, with no second declaration to keep in sync. The same [number] indexed access works on any
array type — see typeof for how the pieces fit together.
Indexing lies by default
This is the sharpest edge on the whole type, and it is on in every default strict project:
const colourNames: string[] = ['red', 'green']
const missingColour = colourNames[99] // typed string — at runtime it is undefined
console.log(missingColour.toUpperCase()) // compiles, throwsTypeScript types arr[i] as T, not T | undefined, even though no bounds check happened. It is
a deliberate trade: adding undefined to every index access would make ordinary loop code
unbearable, so strict leaves it off.
The fix is a separate flag in your tsconfig.json:
{
"compilerOptions": {
"noUncheckedIndexedAccess": true
}
}With it on, colourNames[99] is string | undefined and the compiler makes you handle the miss.
Turning it on in an existing codebase produces a lot of errors at once, most of them real. If that
is too much at once, .at() is the per-call version — it returns T | undefined regardless of the
flag, and it takes negative indices:
[object Object]Arrays of objects, and arrays of arrays
Nothing new in the syntax, but the inference is worth watching.
type TeamMember = { name: string; admin: boolean }
const teamRoster: TeamMember[] = [
{ name: 'ada', admin: true },
{ name: 'grace', admin: false },
]
const gridRows: number[][] = [
[1, 2],
[3, 4],
]Annotating the array, as above, gives you excess property checks on every element — a stray key in one object is an error pointed at that object, not a confusing failure somewhere downstream. Drop the annotation and you lose that: inference widens to the union of whatever shapes it found, so a typo in one entry becomes an optional property on the element type rather than a mistake. Annotate the array literal whenever the shape is already named.
Array methods preserve the element type through the chain, and the typed callbacks are most of what makes arrays pleasant in TypeScript:
const adminNames: string[] = teamRoster
.filter((member) => member.admin)
.map((member) => member.name)flatMap handles the map-then-flatten case and can narrow while it filters, some answers the "is there at least one" question, and the spread operator is the shortest way to copy or merge.
Narrowing something unknown into an array
Data that arrives from JSON, a form, or a third-party SDK is typed unknown if you are being
honest about it. Array.isArray is the narrowing tool, and it is worth knowing exactly how much
it buys you.
function totalOf(payload: unknown): number {
if (!Array.isArray(payload)) return 0
// payload is any[] here — the check proves it is an array, nothing about the elements
return payload.length
}The check narrows unknown to any[], not to number[]. That is the honest answer: at runtime
Array.isArray looks at the object, never at what is inside it. Checking the elements is a
separate job, and a type predicate is the tidy way to package both halves:
function isNumberList(value: unknown): value is number[] {
return Array.isArray(value) && value.every((entry) => typeof entry === 'number')
}
function sumOf(payload: unknown): number {
if (!isNumberList(payload)) return 0
return payload.reduce((running, entry) => running + entry, 0)
}The predicate costs one pass over the data, which is the price of actually knowing. Skipping it
and writing payload as number[] costs nothing and tells you nothing — the assertion silences
the compiler without checking a single element, and the error resurfaces later in a stack trace
that no longer mentions the cast.
Generic functions over arrays
Once a function should work for any element type, the array type gets a parameter instead of a concrete type. This is the everyday use of generics, and arrays are where most people meet them:
function firstOrNull<Element>(items: readonly Element[]): Element | null {
return items.length > 0 ? items[0] : null
}
const firstName = firstOrNull(['ada', 'grace']) // string | null
const firstScore = firstOrNull([1, 2, 3]) // number | nullElement is inferred from the call site, so both uses get a precise return type from one
implementation. Note the readonly parameter again, and note that the length > 0 check is what
makes the items[0] access honest here — without it, the default index typing from the previous
section would have handed back Element for an empty array.
When your array should have been a tuple
An array type says "how" — every element is a T. It says nothing about how many. If the length
is fixed and the positions mean different things, you want a tuple:
const looseCoords: number[] = [51.5, -0.12] // length 0, 2 or 200 all type-check
const strictCoords: [number, number] = [51.5, -0.12] // exactly twoThe giveaway is destructuring. If you write const [a, b] = value and the names are not
interchangeable, the type should have been a tuple. Tuples covers
labels, optional positions, and variadic forms.
Summary
The TypeScript array is simple on the surface and has three things worth keeping in your head:
T[]andArray<T>are the same type.readonly T[]andReadonlyArray<T>are too, andreadonlyis the better default for parameters.- Inference widens literals to
T[];as constfreezes them into a readonly tuple you can turn into a union with[number]. - Index access is typed
Teven when the element does not exist. Turn onnoUncheckedIndexedAccess, or use.at(), before you trust it.
Ready to use it at the type level? First of Array and Last of Array are the two shortest challenges that make you index an array type by hand.
Become a TypeScript Pro
Track your progress through 200+ hands-on challenges. Free, sign in with GitHub.
Or start solving right away: explore all TypeScript challenges