Skip to main content

TypeScript Array Map

You're fetching user data from an API, and you need to transform it before rendering. You could write a for loop, manually push to a new array, and track indices—or you could use map and get it done in one clean expression. The map method isn't just convenient; when paired with TypeScript's type system, it catches transformation errors at compile time instead of runtime. In this guide, we'll cover practical patterns for transforming arrays, handling edge cases, and avoiding common pitfalls that trip up even experienced developers.

How TypeScript's Array Map Method Works

The TypeScript array map method applies a function to each element in an array and returns a new array with the transformed values. The original array stays unchanged:

const orderQuantities = [1, 2, 3, 4, 5];
const doubleQuantities = orderQuantities.map((qty) => qty * 2);
console.log(doubleQuantities); // [2, 4, 6, 8, 10]
console.log(orderQuantities); // [1, 2, 3, 4, 5] - original unchanged

The callback you pass to map receives three arguments: the current element, its index, and the entire array. Most of the time you'll only need the element, but the index can be useful for numbered lists or tracking position:

const items = ['apple', 'banana', 'cherry'];
const numbered = items.map((item, index) => `${index + 1}. ${item}`);
console.log(numbered); // ['1. apple', '2. banana', '3. cherry']

Why reach for map over a traditional for loop? It's more concise, makes your intent clear (you're transforming, not mutating), and works beautifully with TypeScript generics to maintain type safety throughout the transformation.

Using TypeScript Types for Better Type Safety

TypeScript types enhance map operations with compile-time checking. When converting data types, TypeScript infers the output type automatically, but you can be explicit for clarity:

const stringIds: string[] = ['1', '2', '3', '4', '5'];
const numericIds: number[] = stringIds.map((str) => Number(str));
console.log(numericIds); // [1, 2, 3, 4, 5]

For more complex transformations, use TypeScript interface definitions to reshape API responses:

interface ApiUser {
id: number;
name: string;
age: number;
email: string;
}

interface UserDisplay {
id: number;
displayName: string;
}

const apiUsers: ApiUser[] = [
{ id: 1, name: 'John', email: 'john@example.com', age: 28 },
{ id: 2, name: 'Jane', email: 'jane@example.com', age: 32 }
];

const mapToDisplay = (user: ApiUser): UserDisplay => ({
id: user.id,
displayName: `${user.name} (${user.age})`
});

const displayUsers: UserDisplay[] = apiUsers.map(mapToDisplay);

This approach gives you better code completion and catches type errors during development. If you accidentally try to access a property that doesn't exist on UserDisplay, TypeScript will flag it immediately. For more complex type requirements, you can incorporate TypeScript utility types to make your transformations more flexible. When working with filtered data in database applications, Complex Filters in Convex offers patterns that pair well with array transformations.

Handling Undefined and Null Values in Array Map

Real-world API data is messy. Fields can be missing, null, or undefined. The nullish coalescing operator (??) gives you a clean way to provide fallback values:

const apiResponse = [
{ id: 1, name: 'John' },
{ id: 2 },
{ id: 3, name: 'Jane' },
];

const displayNames = apiResponse.map((user) => user.name ?? 'Unknown User');
console.log(displayNames); // ['John', 'Unknown User', 'Jane']

The ?? operator only triggers on null or undefined, not on falsy values like 0 or empty strings. This makes it safer than the || operator for default values.

For union types where elements can be completely different shapes, use TypeScript typeof checks to handle each case:

type ApiValue = string | number | { value: number };

const mixedData: ApiValue[] = ['active', 42, { value: 100 }];

const normalized = mixedData.map((item) => {
if (typeof item === 'string') return item.toUpperCase();
if (typeof item === 'number') return item * 2;
return item.value; // TypeScript knows this must be the object case
});

console.log(normalized); // ['ACTIVE', 84, 100]

TypeScript's type narrowing works inside your map callback, so after each typeof check, it knows exactly what type you're dealing with. This catches errors like calling .toUpperCase() on a number before your code ever runs. When building complex web applications, you can use these techniques alongside Convex's API generation to safely transform data coming from various sources.

Mapping Arrays of Objects to New Structures

You'll often need to reshape objects as you map over them—adding computed fields, categorizing data, or preparing objects for different contexts. Here's how to transform database records for UI display:

const dbUsers = [
{ id: 1, name: 'John', age: 25, lastLogin: '2025-01-15' },
{ id: 2, name: 'Jane', age: 30, lastLogin: '2025-01-20' },
];

const userCards = dbUsers.map((user) => ({
id: user.id,
name: user.name,
ageGroup: user.age > 25 ? 'Adult' : 'Young Adult',
status: user.lastLogin > '2025-01-18' ? 'Active' : 'Inactive',
}));

console.log(userCards);
// [
// { id: 1, name: 'John', ageGroup: 'Young Adult', status: 'Inactive' },
// { id: 2, name: 'Jane', ageGroup: 'Adult', status: 'Active' },
// ]

This pattern is everywhere in real applications—transforming API responses for components, reshaping form data before submission, or normalizing data from different sources. For type safety on complex reshaping, define both input and output types with object type definitions:

interface DatabaseUser {
id: number;
name: string;
age: number;
department: string;
}

interface UIUserCard {
id: number;
displayName: string;
category: 'junior' | 'senior';
badge?: string;
}

const dbUsers: DatabaseUser[] = [
{ id: 1, name: 'John', age: 25, department: 'Engineering' },
{ id: 2, name: 'Jane', age: 35, department: 'Design' },
];

const toUserCard = (user: DatabaseUser): UIUserCard => ({
id: user.id,
displayName: `${user.name} (${user.department})`,
category: user.age < 30 ? 'junior' : 'senior',
badge: user.age > 30 ? 'Experienced' : undefined,
});

const userCards: UIUserCard[] = dbUsers.map(toUserCard);

By extracting the transformation into a separate function, you make the logic testable and reusable. TypeScript ensures that your mapping function's return type matches what you promised in the interface.

Extracting Properties with Array Map

Sometimes you just need a list of IDs, names, or other single properties from an array of objects. That's where map shines for extraction:

const products = [
{ id: 101, name: 'Laptop', price: 999 },
{ id: 102, name: 'Mouse', price: 29 },
{ id: 103, name: 'Keyboard', price: 89 },
];

const productIds = products.map((product) => product.id);
console.log(productIds); // [101, 102, 103]

// Perfect for API calls that need just IDs
await deleteProducts(productIds);

You can also flatten nested structures by pulling data up from deep object hierarchies:

const apiData = [
{ user: { id: 1, name: 'John' }, permissions: ['read', 'write'] },
{ user: { id: 2, name: 'Jane' }, permissions: ['read'] },
];

const flatUsers = apiData.map(item => ({
id: item.user.id,
name: item.user.name,
canWrite: item.permissions.includes('write')
}));

console.log(flatUsers);
// [
// { id: 1, name: 'John', canWrite: true },
// { id: 2, name: 'Jane', canWrite: false }
// ]

This flattening pattern is common when dealing with nested API responses. You're essentially "projecting" the data you need into a simpler shape. When you need to aggregate or accumulate values rather than transform them one-to-one, check out the TypeScript reduce method.

Converting Data Types within an Array

Type conversion is a daily task when dealing with form inputs, URL parameters, or API responses that return strings when you need numbers. The map method handles this elegantly:

const toggleStates: string[] = ['true', 'false', 'true'];
const booleans: boolean[] = toggleStates.map((str) => str === 'true');
console.log(booleans); // [true, false, true]

Going the other direction, you can format numbers for display with TypeScript number to string conversions:

const cartPrices: number[] = [19.99, 24.5, 9.95];
const displayPrices: string[] = cartPrices.map((price) => `$${price.toFixed(2)}`);
console.log(displayPrices); // ['$19.99', '$24.50', '$9.95']

When parsing user input, you need to handle invalid values gracefully. Here's a safe approach using TypeScript string to number techniques:

const userInputs: string[] = ['42', '3.14', 'invalid', '100'];
const validNumbers: (number | null)[] = userInputs.map((input) => {
const parsed = parseFloat(input);
return isNaN(parsed) ? null : parsed;
});
console.log(validNumbers); // [42, 3.14, null, 100]

This pattern is safer than crashing on invalid input. You preserve the array structure, mark invalid entries as null, and can filter them out later if needed. These type conversion patterns show up constantly when building forms, processing CSV data, or normalizing API responses.

Applying Custom Logic within Array Map

Real transformations often need conditional logic—different formatting based on status, computed fields, or branching behavior. Here's how to handle order statuses with different message formats:

const orders = [
{ id: 1, total: 100, status: 'pending' },
{ id: 2, total: 200, status: 'shipped' },
{ id: 3, total: 300, status: 'delivered' },
];

const notifications = orders.map((order) => {
if (order.status === 'pending') {
return `Order #${order.id} is awaiting payment`;
} else if (order.status === 'shipped') {
return `Order #${order.id} is on the way`;
} else {
return `Order #${order.id} was delivered`;
}
});

console.log(notifications);
// [
// 'Order #1 is awaiting payment',
// 'Order #2 is on the way',
// 'Order #3 was delivered',
// ]

TypeScript string interpolation keeps your transformations readable, especially when combining multiple fields:

const inventory = [
{ name: 'Laptop', price: 999.99, inStock: true, quantity: 5 },
{ name: 'Phone', price: 699.99, inStock: false, quantity: 0 },
{ name: 'Tablet', price: 399.99, inStock: true, quantity: 12 }
];

const listings = inventory.map(item => {
const availability = item.inStock
? `${item.quantity} available`
: 'Out of stock';
return `${item.name}: $${item.price.toFixed(2)} (${availability})`;
});

For deeply nested or optional properties, TypeScript optional chaining prevents runtime errors:

const userLocations = [
{ user: { name: 'Alice', address: { city: 'New York' } } },
{ user: { name: 'Bob' } },
{ user: { name: 'Charlie', address: { city: 'Boston' } } }
];

const cities = userLocations.map(item => item.user?.address?.city ?? 'Unknown');
console.log(cities); // ['New York', 'Unknown', 'Boston']

When your transformation logic gets complex, extract it into a named function. This makes the code testable and keeps your map call clean:

interface Order {
id: number;
status: string;
total: number;
}

function formatOrderNotification(order: Order): string {
const { id, status, total } = order;
const amount = `$${total.toFixed(2)}`;

return status === 'pending'
? `Order #${id} (${amount}) needs payment`
: `Order #${id} (${amount}) status: ${status}`;
}

const notifications = orders.map(formatOrderNotification);

Extracting transformation logic like this also makes it easier to unit test the function independently from your data processing pipeline.

When to Use Map vs forEach

You've probably seen both map and forEach in codebases and wondered which to use. Here's the key difference: map returns a new array, forEach returns nothing. Choose based on whether you need the transformed results.

Use map when:

  • You need a new array with transformed values
  • You want to chain other array methods (.filter(), .sort(), etc.)
  • You're transforming data for display or further processing

Use forEach when:

  • You're performing side effects (logging, updating a database, mutating external state)
  • You don't need the return value
  • You're iterating purely for the action, not the transformation

Here's a concrete example:

const prices = [10, 20, 30];

// Use map - you need the transformed array
const withTax = prices.map(price => price * 1.08);
console.log(withTax); // [10.8, 21.6, 32.4]

// Use forEach - you're just logging, no return value needed
prices.forEach(price => {
console.log(`Processing price: $${price}`);
});

From a performance standpoint, forEach can be slightly faster on huge arrays since it doesn't allocate a new array. But this difference is negligible for most applications. The real deciding factor is intent: do you need the results, or are you just iterating for side effects?

One gotcha: don't use map if you're ignoring the return value. This is a code smell:

// Bad - using map but ignoring the return value
prices.map(price => console.log(price));

// Good - forEach makes the intent clear
prices.forEach(price => console.log(price));

TypeScript won't stop you from misusing map this way, but your linter might. The TypeScript filter method follows the same principle—use it when you need a filtered array, not for side effects.

Common Pitfalls with Array Map

Even experienced developers hit these map traps. Let's cover the most common ones so you can avoid them.

The parseInt Trap

This is a classic that catches people off guard:

const strings = ['1', '2', '3'];
const numbers = strings.map(parseInt);
console.log(numbers); // [1, NaN, NaN] - Wait, what?

Why does this happen? The map callback receives three arguments: (element, index, array). The parseInt function takes two arguments: (string, radix). When you pass parseInt directly to map, it's getting the index as the radix parameter.

So the actual calls are:

  • parseInt('1', 0) → 1 (radix 0 defaults to 10)
  • parseInt('2', 1) → NaN (radix 1 is invalid)
  • parseInt('3', 2) → NaN (radix 2 can't parse '3')

The fix is simple—wrap it in an arrow function:

const numbers = strings.map(str => parseInt(str));
// Or more explicitly
const numbers = strings.map(str => parseInt(str, 10));

Union Type Arrays

When you have a union type array like (string | number)[], things can get messy:

type MixedItem = { type: 'text'; value: string } | { type: 'number'; value: number };
const items: MixedItem[] = [
{ type: 'text', value: 'hello' },
{ type: 'number', value: 42 }
];

// This will error if you try to access properties without narrowing
const values = items.map(item => {
// TypeScript doesn't know which type 'item' is here
return item.value.toUpperCase(); // Error: Property 'toUpperCase' does not exist on type 'number'
});

You need type narrowing inside your callback:

const values = items.map(item => {
if (item.type === 'text') {
return item.value.toUpperCase();
}
return item.value.toString();
});

Forgetting Map Returns a New Array

A subtle mistake is thinking map modifies the original array:

const products = [
{ id: 1, name: 'Laptop', price: 1000 }
];

products.map(product => {
product.price = product.price * 0.9; // Mutating directly
return product;
});

// This works, but it's mutating the original array AND creating a new one
// If you want to mutate, just use forEach. If you want a new array, don't mutate the original.

Better approaches:

// If you want a new array with changes
const discounted = products.map(product => ({
...product,
price: product.price * 0.9
}));

// If you want to mutate in place (rare, but sometimes needed)
products.forEach(product => {
product.price = product.price * 0.9;
});

Not Handling Async Operations Properly

This is a sneaky one:

const userIds = [1, 2, 3];

// This doesn't work the way you might think
const users = userIds.map(async (id) => {
return await fetchUser(id);
});

// users is now an array of Promises, not users!
console.log(users); // [Promise, Promise, Promise]

We'll cover the correct pattern in the next section.

Async Operations with Array Map

When you need to fetch data or perform other async operations while mapping, you can't just slap async/await in your callback and call it done. The map method doesn't wait for promises—it returns immediately with an array of unresolved promises.

Here's the right way to handle async operations with map:

const userIds = [1, 2, 3];

// map returns Promise<User>[], not User[]
const userPromises = userIds.map(async (id) => {
const response = await fetch(`/api/users/${id}`);
return response.json();
});

// Wait for all promises to resolve
const users = await Promise.all(userPromises);
console.log(users); // Now you have actual user objects

This pattern runs all fetch operations in parallel, which is much faster than doing them sequentially. For more details on working with promises, check out our guide on TypeScript Promise.

Here's a realistic example that handles errors gracefully:

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

async function enrichProducts(productIds: number[]): Promise<(Product | null)[]> {
const promises = productIds.map(async (id) => {
try {
const response = await fetch(`/api/products/${id}`);
if (!response.ok) return null;
return await response.json();
} catch (error) {
console.error(`Failed to fetch product ${id}:`, error);
return null;
}
});

return Promise.all(promises);
}

// Usage
const productIds = [101, 102, 103];
const products = await enrichProducts(productIds);

// Filter out failed requests
const validProducts = products.filter((p): p is Product => p !== null);

The key takeaway: when your map callback is async, always wrap the result with Promise.all() to convert from an array of promises to a promise of an array.

Method Chaining with flatMap

Sometimes you need to both transform and filter, or transform into multiple items. You could chain .map().filter(), but there's a cleaner option: flatMap.

Here's a scenario where flatMap shines:

const users = [
{ name: 'Alice', tags: ['admin', 'user'] },
{ name: 'Bob', tags: ['user'] },
{ name: 'Charlie', tags: [] }
];

// With map + flat (or map + filter)
const allTags = users
.map(user => user.tags)
.flat();
// ['admin', 'user', 'user']

// With flatMap - cleaner
const allTags = users.flatMap(user => user.tags);
// ['admin', 'user', 'user']

But flatMap really proves its worth when you want to conditionally include items:

const orders = [
{ id: 1, items: ['laptop', 'mouse'] },
{ id: 2, items: [] },
{ id: 3, items: ['keyboard'] }
];

// Filter out empty orders AND flatten - with flatMap
const allItems = orders.flatMap(order =>
order.items.length > 0 ? order.items : []
);
// ['laptop', 'mouse', 'keyboard']

// Compare to map + filter + flat
const allItems = orders
.filter(order => order.items.length > 0)
.map(order => order.items)
.flat();

TypeScript handles flatMap type inference beautifully. When you return an array from the callback, it automatically flattens one level and types the result correctly:

const data = [1, 2, 3];

// map gives you number[][]
const doubled = data.map(n => [n, n * 2]);
// [[1, 2], [2, 4], [3, 6]]

// flatMap gives you number[]
const flattened = data.flatMap(n => [n, n * 2]);
// [1, 2, 2, 4, 3, 6]

Use flatMap when you're mapping to arrays and want a flat result, or when you want to filter during a map (by returning empty arrays for items you want to exclude). For simple transformations, stick with map. When you need to filter items based on their values without transforming, use TypeScript filter instead.

Final Thoughts on TypeScript Array Map

The map method is one of TypeScript's most powerful tools for data transformation. Master these patterns and you'll write cleaner, more maintainable code:

  • Use map when you need transformed results, forEach when you're just iterating for side effects
  • Avoid the parseInt trap by wrapping functions that take multiple arguments
  • Handle async operations with Promise.all() to run transformations in parallel
  • Reach for flatMap when you're mapping to arrays or filtering during transformation
  • Extract complex transformation logic into named functions for testability
  • Let TypeScript's type inference work for you, but add explicit types for complex transformations

The beauty of map is its predictability—it never mutates the original array, makes your transformation intent clear, and chains beautifully with other array methods. Whether you're reshaping API responses, normalizing database results, or formatting data for display, map gives you a functional, type-safe approach that catches errors at compile time instead of runtime.

For more collection operations, explore our guide on TypeScript map for the Map data structure, or dive into TypeScript reduce for aggregation patterns.