Skip to main content

TypeScript Lists

You're fetching user data from an API, and suddenly your app crashes because you tried to call .map() on undefined. Or maybe you're iterating through a product catalog and accidentally mutating the original array when you meant to create a copy. These are the kinds of bugs that happen when you don't have a solid grasp of how TypeScript arrays work.

TypeScript doesn't have a separate "List" type. Arrays are your lists, and they're dynamic, resizable, and packed with methods that make data manipulation straightforward. This guide will show you practical techniques for working with TypeScript arrays, from basic operations to advanced patterns you'll actually use in production code.

Creating and Handling Arrays in TypeScript

TypeScript gives you two ways to declare arrays: the bracket notation (type[]) or the generic syntax (Array<type>). Most developers prefer bracket notation because it's cleaner and more concise:

// Array literal with type annotation (preferred)
let statusCodes: number[] = [200, 404, 500];

// Generic syntax (more verbose)
let errorMessages: Array<string> = ["Not Found", "Server Error"];

// Empty array requires explicit typing
let apiResponses: Response[] = [];

Here's the key difference from JavaScript: TypeScript won't let you mix types unless you explicitly allow it. Trying to push a number into a string array fails at compile time, not runtime:

let userNames: string[] = ["alice", "bob"];
// Error: Argument of type 'number' is not assignable to parameter of type 'string'
userNames.push(123);

Basic Array Operations

Once you've created an array, you'll typically need to add, remove, or modify elements. Here are the essential operations:

let productCategories: string[] = ["electronics", "clothing"];

// Add elements
productCategories.push("furniture"); // Adds to end
productCategories.unshift("books"); // Adds to beginning
// Result: ["books", "electronics", "clothing", "furniture"]

// Remove elements
productCategories.pop(); // Removes last item: "furniture"
productCategories.shift(); // Removes first item: "books"
// Result: ["electronics", "clothing"]

// Modify with splice (position, deleteCount, ...items)
productCategories.splice(1, 0, "sports"); // Insert at index 1
// Result: ["electronics", "sports", "clothing"]

productCategories.splice(0, 1, "tech"); // Replace at index 0
// Result: ["tech", "sports", "clothing"]

To learn more about comprehensive array operations, check out the TypeScript array documentation. For more advanced array filtering techniques in Convex applications, see how to implement complex filters in Convex.

Effectively Using TypeScript Array Methods

Array methods are where TypeScript really shines for data manipulation. These higher-order functions let you transform, filter, and reduce data with clean, readable code. Let's look at the ones you'll reach for most often.

forEach() Method

The TypeScript forEach method executes a function for each array element. Use it when you need side effects (like logging, updating state, or making API calls) rather than transforming data:

interface ApiEndpoint {
url: string;
method: string;
}

let endpoints: ApiEndpoint[] = [
{ url: "/users", method: "GET" },
{ url: "/posts", method: "POST" },
{ url: "/comments", method: "DELETE" }
];

// Registering routes with a router
endpoints.forEach((endpoint, index) => {
console.log(`Registering route ${index + 1}: ${endpoint.method} ${endpoint.url}`);
// router.register(endpoint.method, endpoint.url);
});

map() Method

The TypeScript map method creates a new array by transforming each element. It's perfect for extracting properties, formatting data, or converting between types:

interface User {
id: number;
firstName: string;
lastName: string;
email: string;
}

let users: User[] = [
{ id: 1, firstName: "Sarah", lastName: "Johnson", email: "sarah@example.com" },
{ id: 2, firstName: "Mike", lastName: "Chen", email: "mike@example.com" }
];

// Extract just the data you need for a dropdown
let userOptions = users.map(user => ({
value: user.id,
label: `${user.firstName} ${user.lastName}`
}));
// Result: [{ value: 1, label: "Sarah Johnson" }, { value: 2, label: "Mike Chen" }]

filter() Method

Use TypeScript filter to create a subset of an array based on conditions. It's essential for search functionality, data validation, and conditional rendering:

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

let products: Product[] = [
{ name: "Laptop", price: 1200, inStock: true, rating: 4.5 },
{ name: "Mouse", price: 30, inStock: false, rating: 4.2 },
{ name: "Keyboard", price: 80, inStock: true, rating: 4.8 }
];

// Get available products under $1000 with good ratings
let recommendedProducts = products.filter(
product => product.inStock && product.price < 1000 && product.rating >= 4.5
);
// Result: [{ name: "Keyboard", price: 80, inStock: true, rating: 4.8 }]

reduce() Method

The TypeScript reduce method boils down an array to a single value. You'll use it for totals, aggregations, and transforming arrays into objects:

interface Transaction {
id: string;
amount: number;
type: "credit" | "debit";
}

let transactions: Transaction[] = [
{ id: "t1", amount: 100, type: "credit" },
{ id: "t2", amount: 50, type: "debit" },
{ id: "t3", amount: 75, type: "credit" }
];

// Calculate account balance
let balance = transactions.reduce((total, transaction) => {
return transaction.type === "credit"
? total + transaction.amount
: total - transaction.amount;
}, 0);
console.log(balance); // 125

// Group by type
let grouped = transactions.reduce((acc, transaction) => {
if (!acc[transaction.type]) {
acc[transaction.type] = [];
}
acc[transaction.type].push(transaction);
return acc;
}, {} as Record<string, Transaction[]>);

These array methods form the foundation of data manipulation in TypeScript. For real-world applications, you can combine them to create powerful data processing pipelines, similar to what you might do when working with complex filters in Convex.

Working with Typed Arrays in TypeScript

Type safety is what separates TypeScript arrays from plain JavaScript arrays. You're not just declaring an array, you're defining a contract about what data it can hold and how it can be used.

Defining Arrays with Object Types

When your array contains objects, use interfaces or type aliases to define the structure. This catches errors before they hit production:

interface ApiResponse {
id: string;
status: "success" | "error";
data?: any;
timestamp: number;
}

let responses: ApiResponse[] = [
{ id: "req-1", status: "success", data: { userId: 123 }, timestamp: Date.now() },
{ id: "req-2", status: "error", timestamp: Date.now() }
];

// TypeScript catches missing required fields at compile time
// Error: Property 'timestamp' is missing
let invalidResponse: ApiResponse[] = [
{ id: "req-3", status: "success" }
];

Type Inference vs Explicit Typing

TypeScript can infer simple array types, but explicit typing is better for complex structures. It documents your intent and prevents bugs:

// Inference works for primitives
let statusCodes = [200, 404, 500]; // Inferred as number[]

// But explicit typing is clearer for complex data
interface CartItem {
productId: string;
quantity: number;
price: number;
}

let cart: CartItem[] = [
{ productId: "prod-123", quantity: 2, price: 29.99 },
{ productId: "prod-456", quantity: 1, price: 49.99 }
];

Generic Array Functions

Generics let you write reusable functions that work with any array type while maintaining type safety:

function getLastItem<T>(array: T[]): T | undefined {
return array[array.length - 1];
}

function removeDuplicates<T>(array: T[]): T[] {
return [...new Set(array)];
}

// TypeScript infers the return type correctly
let lastUser = getLastItem(users); // Type: User | undefined
let uniqueTags = removeDuplicates(["typescript", "javascript", "typescript"]); // Type: string[]

Read-Only Arrays

Use readonly when you want to prevent mutations. This is especially useful for function parameters where you don't want the function to modify the original array:

function analyzeScores(scores: readonly number[]): { average: number; max: number } {
// Error: Property 'push' does not exist on type 'readonly number[]'
// scores.push(100);

const sum = scores.reduce((acc, score) => acc + score, 0);
return {
average: sum / scores.length,
max: Math.max(...scores)
};
}

const testScores = [85, 92, 78];
const stats = analyzeScores(testScores);
// testScores is guaranteed unchanged

For more information on types, see TypeScript types. When implementing these patterns in Convex applications, follow the Convex best practices for type-safe data modeling.

Spread Operator and Array Destructuring

Modern TypeScript leverages ES6+ features that make working with arrays more elegant. The spread operator and destructuring are two techniques you'll use constantly.

Spread Operator for Arrays

The spread operator (...) lets you copy arrays, combine them, or pass array elements as individual arguments. It creates shallow copies, which is important to remember:

interface Config {
endpoint: string;
timeout: number;
}

let baseConfigs: Config[] = [
{ endpoint: "/api/users", timeout: 3000 },
{ endpoint: "/api/posts", timeout: 5000 }
];

// Copy an array (shallow copy)
let configsCopy = [...baseConfigs];

// Combine arrays
let additionalConfigs: Config[] = [
{ endpoint: "/api/comments", timeout: 4000 }
];
let allConfigs = [...baseConfigs, ...additionalConfigs];

// Add elements while copying
let configsWithDefault = [
{ endpoint: "/api/health", timeout: 1000 },
...baseConfigs
];

Important: The spread operator creates shallow copies. For nested objects, you need to spread at multiple levels:

let nestedArray = [{ items: [1, 2, 3] }];
let shallowCopy = [...nestedArray];
shallowCopy[0].items.push(4); // This modifies the original!

// For deep copies, you need a different approach
let deepCopy = nestedArray.map(obj => ({ ...obj, items: [...obj.items] }));

Array Destructuring

Destructuring extracts values from arrays into distinct variables. It's cleaner than accessing elements by index:

let response = ["success", 200, { userId: 123 }] as const;

// Old way
let status = response[0];
let code = response[1];
let data = response[2];

// Destructuring way
let [status, code, data] = response;

// Skip elements you don't need
let [statusOnly, , dataOnly] = response;

// Rest operator to capture remaining items
let httpCodes = [200, 201, 400, 404, 500];
let [success1, success2, ...errorCodes] = httpCodes;
// errorCodes is [400, 404, 500]

You can also use destructuring with array methods:

interface Coordinate {
x: number;
y: number;
}

let points: Coordinate[] = [
{ x: 10, y: 20 },
{ x: 30, y: 40 }
];

// Destructure in the callback
points.forEach(({ x, y }) => {
console.log(`Point at (${x}, ${y})`);
});

Handling Multidimensional Arrays

Multidimensional arrays are arrays that contain other arrays. You'll use them for game boards, spreadsheet data, coordinate systems, and any time you need to represent grid-like or hierarchical data.

Creating Multidimensional Arrays

TypeScript's type annotations make it clear what structure your nested arrays should have:

// Tic-tac-toe board
type CellValue = "X" | "O" | null;
let board: CellValue[][] = [
["X", "O", null],
[null, "X", "O"],
["O", null, "X"]
];

// CSV data with headers
type SpreadsheetData = string[][];
let salesData: SpreadsheetData = [
["Month", "Revenue", "Expenses"],
["January", "50000", "30000"],
["February", "55000", "32000"]
];

// 3D coordinate system
type Point3D = [number, number, number];
let path: Point3D[] = [
[0, 0, 0],
[1, 2, 3],
[4, 5, 6]
];

Accessing and Modifying Elements

Work with nested arrays using multiple indices. Always check bounds to avoid runtime errors:

// Safe access with bounds checking
function getCellValue(board: CellValue[][], row: number, col: number): CellValue | undefined {
if (row >= 0 && row < board.length && col >= 0 && col < board[row].length) {
return board[row][col];
}
return undefined;
}

// Check winning condition in tic-tac-toe
function checkWinner(board: CellValue[][]): CellValue {
// Check rows
for (let row of board) {
if (row[0] && row[0] === row[1] && row[1] === row[2]) {
return row[0];
}
}
// Check columns
for (let col = 0; col < 3; col++) {
if (board[0][col] && board[0][col] === board[1][col] && board[1][col] === board[2][col]) {
return board[0][col];
}
}
return null;
}

Iterating Through Multidimensional Arrays

Use nested iteration to process grid data:

interface TestResult {
studentName: string;
scores: number[];
}

let testResults: TestResult[] = [
{ studentName: "Alice", scores: [85, 92, 78] },
{ studentName: "Bob", scores: [90, 88, 95] },
{ studentName: "Carol", scores: [72, 85, 80] }
];

// Calculate average for each student
let studentAverages = testResults.map(student => {
const average = student.scores.reduce((sum, score) => sum + score, 0) / student.scores.length;
return {
name: student.studentName,
average: average.toFixed(1)
};
});

// Find highest score across all students
let allScores = testResults.flatMap(student => student.scores);
let highestScore = Math.max(...allScores);

Type-Safe Matrix Operations

Create utility functions with proper type safety for common matrix operations:

type Matrix = number[][];

function createMatrix(rows: number, cols: number, fillValue: number = 0): Matrix {
return Array(rows).fill(null).map(() => Array(cols).fill(fillValue));
}

function transposeMatrix(matrix: Matrix): Matrix {
if (matrix.length === 0) return [];
return matrix[0].map((_, colIndex) => matrix.map(row => row[colIndex]));
}

function addMatrices(a: Matrix, b: Matrix): Matrix {
if (a.length !== b.length || a[0]?.length !== b[0]?.length) {
throw new Error("Matrices must have the same dimensions");
}
return a.map((row, i) => row.map((val, j) => val + b[i][j]));
}

For more advanced array operations, see the TypeScript array documentation. When implementing complex data structures in real applications, refer to the Convex best practices guide.

Sorting and Filtering Arrays

You'll constantly need to sort and filter data in real applications. Whether it's ordering products by price, filtering search results, or ranking users by score, these operations are essential.

Sorting Arrays

The sort() method mutates the original array. TypeScript ensures your comparison function uses the correct types:

interface BlogPost {
title: string;
publishedAt: Date;
views: number;
featured: boolean;
}

let posts: BlogPost[] = [
{ title: "TypeScript Basics", publishedAt: new Date("2024-01-15"), views: 1200, featured: false },
{ title: "Advanced Types", publishedAt: new Date("2024-02-20"), views: 800, featured: true },
{ title: "Async Patterns", publishedAt: new Date("2024-01-28"), views: 1500, featured: true }
];

// Sort by views (descending)
posts.sort((a, b) => b.views - a.views);

// Sort by date (newest first)
posts.sort((a, b) => b.publishedAt.getTime() - a.publishedAt.getTime());

// Sort by multiple criteria (featured first, then by views)
posts.sort((a, b) => {
if (a.featured === b.featured) {
return b.views - a.views;
}
return a.featured ? -1 : 1;
});

// Sort strings alphabetically
posts.sort((a, b) => a.title.localeCompare(b.title));

If you don't want to mutate the original array, create a copy first:

let sortedPosts = [...posts].sort((a, b) => b.views - a.views);
// Original posts array is unchanged

Filtering Arrays

The TypeScript filter method returns a new array with elements that pass your test:

interface Order {
id: string;
status: "pending" | "shipped" | "delivered" | "cancelled";
total: number;
customerId: string;
}

let orders: Order[] = [
{ id: "ord-1", status: "delivered", total: 150, customerId: "cust-123" },
{ id: "ord-2", status: "pending", total: 75, customerId: "cust-456" },
{ id: "ord-3", status: "shipped", total: 200, customerId: "cust-123" }
];

// Get orders for a specific customer
let customerOrders = orders.filter(order => order.customerId === "cust-123");

// Get high-value pending orders
let urgentOrders = orders.filter(
order => order.status === "pending" && order.total > 100
);

// Type guard for filtering
function isActiveOrder(order: Order): boolean {
return order.status === "pending" || order.status === "shipped";
}
let activeOrders = orders.filter(isActiveOrder);

Chaining Operations

Combine filter, map, and sort for powerful data transformations:

// Get titles of featured posts sorted by views
let featuredTitles = posts
.filter(post => post.featured)
.sort((a, b) => b.views - a.views)
.map(post => post.title);

// Get total revenue from delivered orders
let deliveredRevenue = orders
.filter(order => order.status === "delivered")
.reduce((sum, order) => sum + order.total, 0);

When building applications with Convex, these array operations become invaluable. The complex filters in Convex guide demonstrates how to implement advanced filtering logic in your database queries.

Immutable Array Patterns

Immutability is a core principle in modern JavaScript applications, especially when working with React or other frameworks that rely on reference equality. Here's how to work with arrays without mutating them.

Why Immutability Matters

Many frameworks (React, Redux, etc.) use reference equality checks to detect changes. If you mutate an array directly, these checks fail and your UI won't update:

// Bad: Mutates the original array
let items = [1, 2, 3];
items.push(4); // items reference stays the same

// Good: Creates a new array
let items = [1, 2, 3];
let newItems = [...items, 4]; // newItems has a different reference

Adding Elements Immutably

Instead of push(), unshift(), or splice(), use spread operators or array methods that return new arrays:

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

let tasks: Task[] = [
{ id: "1", title: "Write code", completed: false }
];

// Add to end (instead of push)
let tasksWithNew = [...tasks, { id: "2", title: "Review PR", completed: false }];

// Add to beginning (instead of unshift)
let tasksAtStart = [{ id: "0", title: "Planning", completed: true }, ...tasks];

// Insert at specific position
let index = 1;
let taskToInsert = { id: "1.5", title: "Write tests", completed: false };
let tasksWithInserted = [
...tasks.slice(0, index),
taskToInsert,
...tasks.slice(index)
];

Removing Elements Immutably

Use filter() or slice() instead of splice(), pop(), or shift():

// Remove by ID
let tasksWithoutItem = tasks.filter(task => task.id !== "1");

// Remove first element (instead of shift)
let [, ...withoutFirst] = tasks;
// or
let withoutFirst = tasks.slice(1);

// Remove last element (instead of pop)
let withoutLast = tasks.slice(0, -1);

// Remove at specific index
let indexToRemove = 1;
let withoutIndex = [
...tasks.slice(0, indexToRemove),
...tasks.slice(indexToRemove + 1)
];

Updating Elements Immutably

Use map() to create a new array with updated elements:

// Toggle completion status for a specific task
let toggledTasks = tasks.map(task =>
task.id === "1"
? { ...task, completed: !task.completed }
: task
);

// Update multiple properties
let updatedTasks = tasks.map(task =>
task.completed
? task
: { ...task, completed: true, title: `[DONE] ${task.title}` }
);

For complex state management in Convex applications, see the useState less article which demonstrates efficient immutable patterns.

Array Performance Considerations

Performance matters when working with large datasets. Understanding the performance characteristics of different array operations helps you make better choices.

Method Performance Characteristics

Different array operations have different performance implications:

let largeArray = Array.from({ length: 10000 }, (_, i) => i);

// Fast: Direct index access is O(1)
let item = largeArray[5000]; // Instant

// Fast: push/pop at end is O(1)
largeArray.push(10000);
largeArray.pop();

// Slow: unshift/shift at beginning is O(n)
largeArray.unshift(0); // Requires shifting all elements
largeArray.shift(); // Requires shifting all elements

// Slow: splice in middle is O(n)
largeArray.splice(5000, 0, 999); // Requires shifting half the array

When to Use Traditional Loops

Higher-order functions (map, filter, forEach) are readable but slower than traditional loops. For performance-critical code, consider using for loops:

interface DataPoint {
value: number;
timestamp: number;
}

let dataPoints: DataPoint[] = []; // Imagine 100,000 items

// Slower: Creates intermediate arrays
let result = dataPoints
.filter(point => point.value > 0)
.map(point => point.value * 2)
.reduce((sum, value) => sum + value, 0);

// Faster: Single pass with traditional loop
let result = 0;
for (let i = 0; i < dataPoints.length; i++) {
if (dataPoints[i].value > 0) {
result += dataPoints[i].value * 2;
}
}

// Alternative: reduce in single pass
let result = dataPoints.reduce((sum, point) => {
return point.value > 0 ? sum + (point.value * 2) : sum;
}, 0);

Preallocating Arrays

If you know the final size, preallocating can improve performance by reducing memory reallocations:

// Slower: Array grows dynamically
let dynamicArray: number[] = [];
for (let i = 0; i < 10000; i++) {
dynamicArray.push(i);
}

// Faster: Preallocate with known size
let preallocatedArray = new Array<number>(10000);
for (let i = 0; i < 10000; i++) {
preallocatedArray[i] = i;
}

// Even better for simple cases: Array.from
let generatedArray = Array.from({ length: 10000 }, (_, i) => i);

When Arrays Aren't the Right Choice

For certain operations, other data structures perform better:

// Slow: Checking if item exists in large array (O(n))
let tags = ["typescript", "javascript", "react", /* ...hundreds more */];
let hasTag = tags.includes("vue"); // Has to check each element

// Fast: Use Set for membership checks (O(1))
let tagSet = new Set(tags);
let hasTag = tagSet.has("vue"); // Instant lookup

// Slow: Finding items by ID in array (O(n))
let user = users.find(u => u.id === "user-123");

// Fast: Use Map for key-based lookups (O(1))
let userMap = new Map(users.map(u => [u.id, u]));
let user = userMap.get("user-123");

For most applications, readability beats micro-optimizations. But when you're processing thousands of records, these patterns make a difference.

Choosing the Right Data Structure

Arrays aren't always the best choice. TypeScript offers several collection types, each optimized for different use cases. Here's when to use each one.

Arrays vs Set vs Map

Data StructureBest ForLookup SpeedMaintains OrderDuplicates Allowed
ArrayOrdered collections, indexed access, iterationO(n) for find✓ Yes✓ Yes
SetUnique values, membership testsO(1) for has✓ Yes (insertion order)✗ No
MapKey-value pairs, frequent lookups by keyO(1) for get✓ Yes (insertion order)Keys must be unique

When to Use Arrays

Arrays are your default choice for most sequential data. Use them when you need:

// Ordered lists where position matters
let priorities = ["high", "medium", "low"];

// Collections you'll iterate through
let tasks = [
{ id: 1, title: "Write docs" },
{ id: 2, title: "Review PR" }
];

// Data you'll transform with map/filter/reduce
let prices = [19.99, 29.99, 49.99];
let discounted = prices.map(p => p * 0.9);

// Small collections where performance isn't critical
let recentSearches = ["typescript", "arrays", "performance"];

When to Use Set

Switch to a TypeScript Set when you need unique values or fast membership checks:

// Remove duplicates
let tags = ["typescript", "javascript", "typescript", "react"];
let uniqueTags = [...new Set(tags)]; // ["typescript", "javascript", "react"]

// Fast existence checks
let allowedRoles = new Set(["admin", "editor", "viewer"]);
if (allowedRoles.has(userRole)) {
// O(1) lookup instead of O(n) with array
}

// Track unique visitors
let visitorIds = new Set<string>();
visitorIds.add("user-123");
visitorIds.add("user-123"); // Duplicate ignored
console.log(visitorIds.size); // 1

When to Use Map

Use a TypeScript Map when you need to look up values by a key:

// Cache API responses by ID
let userCache = new Map<string, User>();
userCache.set("user-123", { id: "user-123", name: "Alice" });
let user = userCache.get("user-123"); // O(1) lookup

// Group items by category
let productsByCategory = new Map<string, Product[]>();
products.forEach(product => {
if (!productsByCategory.has(product.category)) {
productsByCategory.set(product.category, []);
}
productsByCategory.get(product.category)!.push(product);
});

// Configuration with non-string keys
let configByEndpoint = new Map<RegExp, Config>();
configByEndpoint.set(/^\/api\/users/, { timeout: 3000 });

For more on these alternatives, see the TypeScript Map and TypeScript Set guides.

Working with TypeScript Arrays

TypeScript arrays give you a powerful, type-safe way to work with collections. You've learned how to create and manipulate arrays, transform data with map and filter, maintain immutability, and optimize for performance.

The key takeaways: use explicit types for complex data, reach for immutable patterns in React applications, and choose the right data structure for your use case. Arrays are your go-to for ordered collections, but don't hesitate to switch to Set or Map when you need faster lookups.

For more advanced array operations, see the TypeScript array documentation. When building robust applications with Convex, be sure to follow the best practices for data validation and error handling.