Skip to main content

How to use TypeScript arrays effectively

You declare an empty array and TypeScript types it as never[] - a type that means nothing can ever go into it. You push a string in. Error. Or you define an array with const, assume it's protected from mutation, and watch TypeScript happily let push() through. Or you call .sort() on [99, 5, 149, 29] and get back [149, 29, 5, 99].

Arrays in TypeScript look exactly like JavaScript arrays until the type system quietly changes the rules. The methods are the same, but knowing why those surprises happen - and how to work around them - is what separates code that's type-safe from code that just compiles. This guide covers the full picture: declaration, methods, destructuring, sorting, immutability, and the pitfalls worth knowing before you hit them.

Declaring and Initializing TypeScript Arrays

You can use the T[] syntax or the generic Array<T> form - both are equivalent, but T[] is more common in practice:

// The common approach
let numbers: number[] = [1, 2, 3, 4, 5];

// Generic syntax - same result, different style
let strings: Array<string> = ['apple', 'banana', 'orange'];

// Empty array with explicit type (avoids the never[] trap - more on that below)
let inferredNumbers: number[] = [];

// Using the Array constructor
let zeros: number[] = new Array(3).fill(0);

You can also initialize arrays with complex objects defined by interfaces:

interface User {
name: string;
age: number;
}

const users: User[] = [
{ name: 'John Doe', age: 30 },
{ name: 'Jane Doe', age: 25 }
];

TypeScript will catch errors if you try to add objects that don't match the User interface.

Using Array Methods in TypeScript

TypeScript retains all of JavaScript's built-in array methods while adding type safety. Here's a practical look at the ones you'll use most, starting with the workhorses: TypeScript map, filter, and reduce. When filtering data in TypeScript, you get compile-time type checking for your conditions:

const numbers = [1, 2, 3, 4, 5];

// Transform elements
const doubled = numbers.map(num => num * 2); // number[]
// Select elements
const evenNumbers = numbers.filter(num => num % 2 === 0); // number[]
// Aggregate to a single value
const sum = numbers.reduce((total, num) => total + num, 0); // number
// Check if any element matches
const hasNegative = numbers.some(num => num < 0); // boolean
// Verify all elements match
const allPositive = numbers.every(num => num > 0); // boolean

Beyond those, you'll also reach for these regularly:

const products = ['laptop', 'mouse', 'keyboard', 'monitor'];

// Find the first match (returns undefined if not found)
const firstLong = products.find(p => p.length > 5); // string | undefined

// Get the index of a match (-1 if not found)
const mouseIndex = products.findIndex(p => p === 'mouse'); // number

// Check membership
const hasLaptop = products.includes('laptop'); // boolean

// Extract a portion without mutating the original
const midRange = products.slice(1, 3); // ['mouse', 'keyboard']

// Remove or replace elements (mutates - use with caution)
const removed = products.splice(1, 1); // removes 'mouse'

find() returns T | undefined, not just T. TypeScript won't let you use the result without handling the missing case first.

For a deeper look at adding elements, check out TypeScript append to array. For removal patterns, see removing items from TypeScript arrays.

Array Destructuring and the Spread Operator

Destructuring and spread are two of the most useful tools for working with arrays in TypeScript, and both play well with the type system.

Destructuring pulls out elements by position:

const scores = [95, 87, 72, 88, 91];

// Pull out specific elements
const [first, second, ...rest] = scores;
// first: number, second: number, rest: number[]

// Skip elements with commas
const [top, , third] = scores;

// With a typed tuple
const point: [number, number] = [10, 20];
const [x, y] = point; // x: number, y: number

Spread lets you merge or copy arrays without mutation:

const defaults = [1, 2, 3];
const extras = [4, 5, 6];

// Merge two arrays
const combined = [...defaults, ...extras]; // [1, 2, 3, 4, 5, 6]

// Copy an array (avoids shared references)
const copy = [...defaults];

// Add elements without touching the original
const withNew = [...defaults, 7, 8]; // [1, 2, 3, 7, 8]

The spread approach is the preferred way to merge arrays in modern TypeScript because it's non-mutating and type inference works cleanly. When you spread two number[] arrays together, you get a number[] back.

Sorting TypeScript Arrays

Sorting works the same as JavaScript, but TypeScript adds guardrails. For primitive arrays, the default .sort() is lexicographic, which trips up developers working with numbers:

// This works for strings
const names = ['Charlie', 'Alice', 'Bob'];
names.sort(); // ['Alice', 'Bob', 'Charlie'] - correct

// For numbers, always provide a comparator
const prices = [99, 5, 149, 29];
prices.sort(); // [149, 29, 5, 99] - WRONG (lexicographic)
prices.sort((a, b) => a - b); // [5, 29, 99, 149] - correct

You'll often need to sort objects by a specific property. Here's the pattern:

interface Product {
name: string;
price: number;
rating: number;
}

const catalog: Product[] = [
{ name: 'Laptop', price: 999, rating: 4.5 },
{ name: 'Mouse', price: 29, rating: 4.8 },
{ name: 'Monitor', price: 349, rating: 4.2 },
];

// Sort by price ascending
const byPrice = [...catalog].sort((a, b) => a.price - b.price);

// Sort by name alphabetically
const byName = [...catalog].sort((a, b) => a.name.localeCompare(b.name));

// Sort by rating descending
const topRated = [...catalog].sort((a, b) => b.rating - a.rating);

Notice the [...catalog] spread before sorting. .sort() mutates the original array in place, so spreading first gives you a sorted copy without changing the source data.

Readonly Arrays and Immutability in TypeScript

If you want to prevent an array from being modified after creation, TypeScript gives you a few tools.

readonly on an array type prevents mutation methods like push(), pop(), and splice():

const config: readonly string[] = ['development', 'staging', 'production'];

// config.push('test'); // Error: Property 'push' does not exist on type 'readonly string[]'

// ReadonlyArray<T> is equivalent
const environments: ReadonlyArray<string> = ['dev', 'prod'];

For literal arrays where you also want TypeScript to narrow the type to specific values, use as const:

const DIRECTIONS = ['north', 'south', 'east', 'west'] as const;
// Type: readonly ["north", "south", "east", "west"] - not string[]

type Direction = typeof DIRECTIONS[number]; // "north" | "south" | "east" | "west"

This lets you derive a union type directly from the array values, so you only define them once.

Ensuring Type Safety with Arrays in TypeScript

TypeScript's type system catches array problems at compile time that would otherwise blow up at runtime:

// Mixed types get a union type
const mixedArray = [1, 'two', 3]; // (string | number)[]

interface Task {
id: number;
title: string;
completed: boolean;
}

const tasks: Task[] = [
{ id: 1, title: 'Learn TypeScript', completed: false },
{ id: 2, title: 'Build project', completed: true }
];

// TypeScript catches missing or incorrect properties
tasks.push({ id: 3, title: 'Test code' }); // Error: missing 'completed'

// Type narrowing with array methods
const completedTasks = tasks.filter((task): task is Task => task.completed);

When building applications with Convex, you can combine TypeScript's type checking with complex filters to ensure your data queries are type-safe end-to-end.

Handling Arrays of Complex Objects in TypeScript

When arrays hold nested objects, TypeScript follows your interface definitions all the way down. You get full autocomplete on nested properties:

interface Address {
street: string;
city: string;
}

interface User {
name: string;
address: Address;
}

const users: User[] = [
{ name: 'John Doe', address: { street: '123 Main St', city: 'Anytown' } },
{ name: 'Jane Doe', address: { street: '456 Elm St', city: 'Othertown' } }
];

users.forEach((user) => {
console.log(user.address.city);
});

You can also use type assertions when working with data from an untyped source, though be careful - type assertions tell TypeScript to trust you, not the other way around:

const users: any[] = [
{ name: 'John Doe', age: 30 },
{ name: 'Jane Doe', age: 25 }
];

users.forEach((user) => {
const typedUser = user as User;
console.log(typedUser.name);
});

As your data shapes get more complex, it helps to understand how they're generated. Check out how to uncover API generation secrets for handling sophisticated data patterns.

Iterating Over Arrays in TypeScript

TypeScript tracks types consistently across all loop styles. The foreach method is the go-to when you just need to run something on each element:

interface Product {
name: string;
price: number;
inStock: boolean;
}

const products: Product[] = [
{ name: "Laptop", price: 999, inStock: true },
{ name: "Mouse", price: 29, inStock: false }
];

// forEach for side effects (no early exit available)
products.forEach(product => {
if (product.inStock) {
console.log(`${product.name}: $${product.price}`);
}
});

// for...of when you need break or continue
for (const product of products) {
if (!product.inStock) continue;
// Process in-stock items
}

// Classic for loop for index access
for (let i = 0; i < products.length; i++) {
const product = products[i];
// Access both index and element
}

Check out more TypeScript examples and patterns for working with data in your applications.

Implementing Multidimensional Arrays in TypeScript

A 2D array in TypeScript uses the [][] syntax:

const matrix: number[][] = [[1, 2], [3, 4]];

Access elements in a 2D array using indices:

console.log(matrix[0][0]); // Output: 1
console.log(matrix[1][1]); // Output: 4

The tuple type is particularly useful for fixed-size arrays. For complex data structures like these, you might want to explore techniques for validating nested data in your application.

Managing Array Type Assertions and Type Guards in TypeScript

Type guards give TypeScript the runtime evidence it needs to narrow array element types. They're the right tool when you're working with arrays of mixed or unknown types:

// Type assertion (use sparingly - bypasses type checking)
const data: any[] = [1, 2, 3];
const numbers = data as number[]; // Compiles but could fail at runtime

// Better: type guard function
function isString(value: unknown): value is string {
return typeof value === 'string';
}

// Array with mixed types
const mixed = ['hello', 1, 'world', 2, true];

// Filter to get only strings with type safety
const strings = mixed.filter(isString); // string[]

// Type guard for object shapes
interface User {
id: number;
name: string;
}

function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value
);
}

// Using type guards for safer data handling
const rawData = [
{ id: 1, name: 'Alice' },
{ id: 'invalid' },
{ id: 2, name: 'Bob' }
];

const validUsers = rawData.filter(isUser); // User[]

Where TypeScript Array Developers Get Stuck

A few gotchas come up often enough that they're worth calling out directly.

Empty arrays default to never[]

If you declare an array without a type annotation and TypeScript can't infer one from context, it'll type it as never[], which means you can't add anything to it:

// Avoid this
const items = []; // never[]
items.push('hello'); // Error: Argument of type 'string' is not assignable to type 'never'

// Fix: explicitly type your empty arrays
const items: string[] = [];

const doesn't make arrays immutable

const prevents reassignment, but the contents are still mutable. This surprises developers coming from other languages:

const config = ['prod', 'staging'];
config.push('dev'); // No error - const doesn't prevent mutation
config = ['dev']; // Error - this is what const prevents

// Use readonly for actual immutability
const frozen: readonly string[] = ['prod', 'staging'];
frozen.push('dev'); // Error: Property 'push' does not exist on type 'readonly string[]'

Type widening in mixed arrays

When you mix types, TypeScript infers the widest union type that covers all elements. This can cause issues downstream if you expect a specific type:

const values = [1, 'two', true]; // (string | number | boolean)[]

// You might expect number, but you get string | number | boolean
const first = values[0]; // string | number | boolean

If this array is meant to hold a specific type, annotate it explicitly rather than relying on inference.

Final Thoughts on TypeScript Arrays

TypeScript doesn't reinvent arrays - it adds compile-time checking that catches real bugs before they ship. A few rules worth keeping:

  • Always type empty arrays explicitly to avoid never[] surprises
  • Use readonly or ReadonlyArray<T> when an array shouldn't change after creation
  • Always provide a comparator when sorting numbers - the default is lexicographic, not numeric
  • Spread with [...array] before sorting to avoid mutating the source
  • Prefer type guards over as assertions for safer runtime handling

Get these right and TypeScript's type system becomes something that works with you, not against you.