TypeScript Array Types

November 2, 202610 min read
Requirements:
Arrays| UnionsGenerics

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 array

ReadonlyArray<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 copy

That 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 line

as 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, throws

TypeScript 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 | null

Element 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 two

The 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:

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.

Share this article

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

Practice with Challenges

Put your typescript array types knowledge to the test with these related challenges.

#14First of Array
Easy
#15Last of Array
Medium
#34007Compare Array Length
Medium

Related Concepts

Concepts that build on or relate to typescript array types.

TypeScript TuplesThe JavaScript Spread Operator in TypeScriptflatMap in TypeScriptJavaScript some in TypeScript