Skip to main content

Adding Elements to Arrays in TypeScript

You're updating state in React and your component isn't re-rendering. You check the code and realize you mutated the array directly with push() instead of creating a new one. Or maybe you tried using the spread operator on a huge dataset and hit a "Maximum call stack size exceeded" error. Choosing the right method to add elements to TypeScript arrays matters more than you might think.

The push() Method: Simple and Direct

The push() method adds one or more elements to the end of an array and returns the new length. It's straightforward and modifies the array in place:

let numbers: number[] = [1, 2, 3];
numbers.push(4);

console.log(numbers); // Output: [1, 2, 3, 4]

TypeScript arrays give you type safety out of the box, catching errors at compile time instead of runtime:

let numbers: number[] = [1, 2, 3];

// numbers.push('4'); // Error: Argument of type 'string' is not assignable to parameter of type 'number'.

numbers.push(4);
console.log(numbers); // Output: [1, 2, 3, 4]

You can also add multiple elements at once:

let numbers: number[] = [1, 2, 3];
numbers.push(4, 5, 6);

console.log(numbers); // Output: [1, 2, 3, 4, 5, 6]

Using the Spread Operator

The spread operator (...) creates a new array instead of modifying the existing one, which matters when you need immutability:

let numbers: number[] = [1, 2, 3];
let newNumber: number = 4;

numbers = [...numbers, newNumber];
console.log(numbers); // Output: [1, 2, 3, 4]

You can combine multiple arrays easily:

let numbers: number[] = [1, 2, 3];
let moreNumbers: number[] = [4, 5, 6];

numbers = [...numbers, ...moreNumbers];
console.log(numbers); // Output: [1, 2, 3, 4, 5, 6]

The spread operator also lets you add elements at any position:

let numbers: number[] = [1, 2, 3];
let newNumber: number = 0;

// Add at the beginning
numbers = [newNumber, ...numbers];
console.log(numbers); // Output: [0, 1, 2, 3]

// Add in the middle
numbers = [...numbers.slice(0, 2), 99, ...numbers.slice(2)];
console.log(numbers); // Output: [0, 1, 99, 2, 3]

If you're using push() but need to add filtered data, you can combine it with the spread operator:

let numbers: number[] = [1, 2, 3];
let moreNumbers: number[] = [4, 5, 6];

numbers.push(...moreNumbers);
console.log(numbers); // Output: [1, 2, 3, 4, 5, 6]

This works well when processing items with array filter or foreach before adding them. You'll see this pattern frequently when adding filtered data to an existing collection.

Conditionally Adding Elements

Sometimes you only want to add an element if it meets certain criteria:

let numbers: number[] = [1, 2, 3];
let newNumber: number = 4;

if (!numbers.includes(newNumber)) {
numbers.push(newNumber);
}

console.log(numbers); // Output: [1, 2, 3, 4]

This prevents duplicates, which you'll need when working with unique identifiers or building features that require efficient data filtering.

For more complex logic, combine the ternary operator with your conditions:

let numbers: number[] = [1, 2, 3];
let potentialNewNumber: number | null = getUserInput();

// Only add if it exists and is positive
potentialNewNumber && potentialNewNumber > 0 ? numbers.push(potentialNewNumber) : null;

console.log(numbers);

When working with TypeScript's type system, you can build sophisticated conditional logic while keeping everything type-safe.

Adding at Specific Positions

Adding to the Beginning with unshift()

The unshift() method adds elements to the start of an array:

let numbers: number[] = [2, 3, 4];
numbers.unshift(1);

console.log(numbers); // Output: [1, 2, 3, 4]

Like push(), you can add multiple elements at once:

let numbers: number[] = [4, 5, 6];
numbers.unshift(1, 2, 3);

console.log(numbers); // Output: [1, 2, 3, 4, 5, 6]

Inserting at a Specific Index with splice()

When you need to insert elements at a specific position, use splice():

let fruits: string[] = ['apple', 'banana', 'orange'];

// Insert 'grape' at index 1 (0 means delete nothing)
fruits.splice(1, 0, 'grape');

console.log(fruits); // Output: ['apple', 'grape', 'banana', 'orange']

You can insert multiple items at once:

let fruits: string[] = ['apple', 'orange'];

// Insert multiple items at index 1
fruits.splice(1, 0, 'banana', 'grape', 'mango');

console.log(fruits); // Output: ['apple', 'banana', 'grape', 'mango', 'orange']

Type Safety When Adding Elements

TypeScript catches type mismatches at compile time, but you still need runtime checks when dealing with external data:

let numbers: number[] = [1, 2, 3];

try {
// TypeScript catches this at compile time
// numbers.push('4'); // Error: Argument of type 'string' is not assignable to parameter of type 'number'.

// Runtime validation for external data
const input: any = getUserInput(); // Could be anything
if (typeof input === 'number') {
numbers.push(input);
} else {
throw new Error('Input must be a number');
}
} catch (error) {
console.error(error);
}

console.log(numbers);

Type guards give you more control when handling unknown types:

function addNumberToArray(arr: number[], value: unknown): number[] {
if (typeof value !== 'number') {
return arr; // Return unchanged if value isn't a number
}
return [...arr, value];
}

numbers = addNumberToArray(numbers, '5'); // Array unchanged
numbers = addNumberToArray(numbers, 5); // Array becomes [1, 2, 3, 5]

You'll need this pattern when handling type safety in database operations in full-stack TypeScript applications.

Working with Union Types and Complex Objects

When your array uses union types, you'll need type guards to ensure compatibility:

let numbers: number[] = [1, 2, 3];
let newNumber: number | string = 4;

// Narrow the type before adding
if (typeof newNumber === 'number') {
numbers.push(newNumber);
}
console.log(numbers); // Output: [1, 2, 3, 4]

For complex objects, validate the structure before adding with type assertion:

type Item = { id: number; value: string };
let items: Item[] = [{ id: 1, value: 'one' }];
let newItem: unknown = { id: 2, value: 'two' };

// Verify structure matches before adding
if (
typeof newItem === 'object' &&
newItem !== null &&
'id' in newItem &&
'value' in newItem
) {
items.push(newItem as Item);
}

console.log(items);

This matters when working with complex data structures from APIs or databases. When using Convex's document database, combining proper type checking with TypeScript typeof keeps your arrays consistent as they grow.

Choosing the Right Method: push() vs concat() vs Spread Operator

Each method for adding elements has different characteristics. Here's when to use each one:

MethodMutates Original?PerformanceBest For
push()YesFastModifying existing arrays, performance-critical code
concat()NoFast (especially for large arrays)Combining arrays immutably, working with large datasets
Spread ...NoSlower for large arraysSmall arrays, clean syntax, modern codebases

When to Use push()

Use push() when you're okay with mutating the array and need performance:

let activityLog: string[] = [];

function logActivity(message: string) {
activityLog.push(message); // Fast, direct mutation
}

When to Use concat()

Use concat() for large arrays or when you need to handle non-array values:

let numbers: number[] = [1, 2, 3];
let largeDataset: number[] = new Array(10000).fill(0).map((_, i) => i);

// concat() is more efficient and won't hit stack limits
numbers = numbers.concat(largeDataset);

When to Use the Spread Operator

Use spread for smaller arrays in modern codebases where clean syntax matters:

let userIds: number[] = [1, 2, 3];
let newUserId: number = 4;

// Clean, readable, immutable
userIds = [...userIds, newUserId];

Warning: The spread operator can throw "Maximum call stack size exceeded" with very large arrays. If you're merging arrays with thousands of elements, use concat() instead.

Mutable vs Immutable: When It Actually Matters

Understanding when to mutate arrays versus creating new ones can make or break your application's behavior.

When Immutability Is Critical

React and UI State: React's state comparison uses shallow equality checks. Mutating an array won't trigger a re-render:

// This won't trigger a re-render
const [items, setItems] = useState<string[]>(['apple', 'banana']);

function addItem(newItem: string) {
items.push(newItem); // BAD: Mutates the array
setItems(items); // React won't detect the change
}

// This will trigger a re-render
function addItemCorrectly(newItem: string) {
setItems([...items, newItem]); // Creates a new array reference
}

You'll see this pattern when managing query state in applications.

Redux and State Management: Redux requires immutability for state updates to work correctly:

// Redux reducer (simplified)
function todosReducer(state: Todo[] = [], action: Action) {
switch (action.type) {
case 'ADD_TODO':
// Return a new array, don't mutate
return [...state, action.payload];
default:
return state;
}
}

When Mutation Is Fine

Building Local Arrays: If you're constructing an array that hasn't been exposed yet, mutation is faster:

function processUserData(rawData: RawUser[]): User[] {
const users: User[] = [];

for (const raw of rawData) {
if (raw.isActive) {
users.push(transformUser(raw)); // Mutation is fine here
}
}

return users; // Nobody has a reference yet
}

Performance-Critical Loops: When processing large datasets outside of reactive contexts:

function generateSequence(length: number): number[] {
const sequence: number[] = [];

for (let i = 0; i < length; i++) {
sequence.push(i); // Much faster than creating new arrays each iteration
}

return sequence;
}

When working with complex filtering logic, choose your approach based on whether the array is part of tracked state.

Performance: What You Need to Know

The Stack Limit Problem

The spread operator has a hidden gotcha. With large arrays, you'll hit JavaScript's maximum call stack size:

let smallArray = [1, 2, 3];
let largeArray = new Array(100000).fill(0);

// This works fine
let combined1 = [...smallArray, ...smallArray]; // No problem

// This will crash
// let combined2 = [...largeArray, ...largeArray]; // RangeError: Maximum call stack size exceeded

// Use concat() instead
let combined3 = largeArray.concat(largeArray); // Works perfectly

Benchmarking Different Approaches

Here's roughly what you can expect for performance (your results will vary):

// For adding 10,000 items to an array:

// push() - ~1-2ms (fastest)
let arr1: number[] = [];
for (let i = 0; i < 10000; i++) {
arr1.push(i);
}

// Array.from() - ~2-3ms
let arr2 = Array.from({ length: 10000 }, (_, i) => i);

// concat() - ~3-5ms
let arr3: number[] = [];
arr3 = arr3.concat(Array.from({ length: 10000 }, (_, i) => i));

// Repeated spread operator - ~15-20ms (slowest, and fails at larger sizes)
let arr4: number[] = [];
for (let i = 0; i < 10000; i++) {
arr4 = [...arr4, i]; // Creates a new array every iteration!
}

The key insight: Don't use the spread operator inside loops. It creates a new array on every iteration, which kills performance.

Wrapping Up

Here's what you need to remember about adding elements to TypeScript arrays:

  • Use push() for straightforward mutations when performance matters and you're not dealing with reactive state
  • Reach for the spread operator in React, Redux, and other state management scenarios where immutability is required
  • Choose concat() when working with large arrays to avoid stack overflow errors
  • Add TypeScript runtime checks when dealing with external data, not just compile-time types
  • Never use the spread operator inside loops if you care about performance

The method you pick depends on your context. Building a local array before returning it? Mutate away with push(). Updating React state? You need the spread operator or concat(). Processing thousands of records from an API? Skip the spread operator and use concat() or push() depending on whether you need immutability.

TypeScript's type system catches mistakes early, but understanding the performance and mutability implications of each approach will save you from subtle bugs and slow code down the line.