To merge two arrays in TypeScript, use concat() or spread syntax: a.concat(b) and [...a, ...b] both return a new array and leave the originals alone. That is all you need when the rows are independent. When both arrays hold objects that describe the same records, you usually want one row per id instead of two, which is a Map away. This guide covers plain concatenation, merging by id with either side winning, merging the objects themselves, flattening arrays of arrays, and why concat() inside a loop is the slow way to build a big list.
Every example was compiled and run with TypeScript 5.2.2 and Node.js v22.22.2 in the Windows Command Prompt, with tsc --target es2022 --lib es2023,dom --strict file.ts and then node file.js. The output is the real output. Reference: Array.prototype.concat() on MDN.
Merge two arrays in TypeScript
Three ways, and they differ only in whether you get a new array or change an existing one. TypeScript checks the element types, so merging a string[] into an Order[] will not compile:
interface Order {
id: string;
customer: string;
total: number;
}
const fromApi: Order[] = [
{ id: "ORD-1001", customer: "Emma Johnson", total: 249.99 },
{ id: "ORD-1002", customer: "Michael Brown", total: 89.5 },
];
const fromCache: Order[] = [
{ id: "ORD-1002", customer: "Michael Brown", total: 95.0 }, // same id, newer total
{ id: "ORD-1003", customer: "Olivia Davis", total: 430 },
];
// concat(): a new array, both originals untouched
const joined = fromApi.concat(fromCache);
console.log(joined.map(o => o.id), fromApi.length);
// spread: the same result, newer syntax, and it takes any number of arrays
console.log([...fromApi, ...fromCache].map(o => o.id));
// push with spread: adds into the array you already have
const running: Order[] = [...fromApi];
running.push(...fromCache);
console.log(running.length, "rows after push");
// concat also accepts single items alongside arrays
console.log(fromApi.concat({ id: "ORD-1004", customer: "Noah", total: 15 }).length);
Output:
[ 'ORD-1001', 'ORD-1002', 'ORD-1002', 'ORD-1003' ] 2
[ 'ORD-1001', 'ORD-1002', 'ORD-1002', 'ORD-1003' ]
4 rows after push
3
concat() and spread leaving the sources untouched.| What you want | Use |
|---|---|
| A new combined array | a.concat(b) or [...a, ...b] |
| Add into an existing array | a.push(...b) |
| One row per id, later wins | new Map([...a, ...b].map(o => [o.id, o])) |
| One row per id, first wins | only set ids you have not seen |
| Fields from both rows | { ...current, ...incoming } |
| Many arrays into one | arrays.flat() |
| Map and flatten together | items.flatMap(...) |
Merge arrays of objects by id
Concatenating two sources of the same records leaves duplicates. Keying a Map on the id collapses them, and the order you feed the arrays in decides which version survives. The third block merges the two versions field by field instead of discarding one, which is what you want when each source knows different columns. For the general case see removing duplicate objects:
interface Order {
id: string;
customer: string;
total: number;
}
const fromApi: Order[] = [
{ id: "ORD-1001", customer: "Emma Johnson", total: 249.99 },
{ id: "ORD-1002", customer: "Michael Brown", total: 89.5 },
];
const fromCache: Order[] = [
{ id: "ORD-1002", customer: "Michael Brown", total: 95.0 }, // same id, newer total
{ id: "ORD-1003", customer: "Olivia Davis", total: 430 },
];
// merge by id: later rows win, and each id appears once
const merged = [...new Map([...fromApi, ...fromCache].map(order => [order.id, order])).values()];
console.log(merged.map(o => `${o.id} $${o.total}`));
// keep the FIRST version of each id instead
const seen = new Map<string, Order>();
for (const order of [...fromApi, ...fromCache]) {
if (!seen.has(order.id)) seen.set(order.id, order);
}
console.log([...seen.values()].map(o => `${o.id} $${o.total}`));
// combine the two versions field by field rather than replacing
const combined = new Map<string, Order>();
for (const order of [...fromApi, ...fromCache]) {
const current = combined.get(order.id);
combined.set(order.id, current ? { ...current, ...order } : order);
}
console.log([...combined.values()].map(o => `${o.id} ${o.customer} $${o.total}`));
Output:
[ 'ORD-1001 $249.99', 'ORD-1002 $95', 'ORD-1003 $430' ]
[ 'ORD-1001 $249.99', 'ORD-1002 $89.5', 'ORD-1003 $430' ]
[
'ORD-1001 Emma Johnson $249.99',
'ORD-1002 Michael Brown $95',
'ORD-1003 Olivia Davis $430'
]
Merging the objects themselves
Object spread copies one level. A nested object in the second argument replaces the whole nested object from the first, it does not merge into it. This surprises people with settings and configuration objects:
interface Settings {
theme: string;
notifications: { email: boolean; sms: boolean };
}
const defaults: Settings = { theme: "light", notifications: { email: true, sms: false } };
const saved = { theme: "dark", notifications: { sms: true } };
// object spread is SHALLOW: the nested object is replaced, not merged
const shallow = { ...defaults, ...saved };
console.log(JSON.stringify(shallow));
// Object.assign behaves the same way, it just mutates the first argument
console.log(JSON.stringify(Object.assign({}, defaults, saved)));
// merge the nested level yourself
const deep = {
...defaults,
...saved,
notifications: { ...defaults.notifications, ...saved.notifications },
};
console.log(JSON.stringify(deep));
Output:
{"theme":"dark","notifications":{"sms":true}}
{"theme":"dark","notifications":{"sms":true}}
{"theme":"dark","notifications":{"email":true,"sms":true}}
email: true. Line 3 keeps it, because the nested level is merged as well.There is no built-in deep merge. For two or three known levels, spread each one as above. For arbitrary depth, use a library rather than writing a recursive merge that has to decide what to do with arrays.
Applying partial updates
A patch that carries only the changed fields is typed Partial<Order> with the id made required again. Look each one up, spread it over the current row, and decide what to do when the id is unknown. This pattern keeps an array of objects in sync with a stream of updates:
interface Order {
id: string;
customer: string;
total: number;
}
const fromApi: Order[] = [
{ id: "ORD-1001", customer: "Emma Johnson", total: 249.99 },
{ id: "ORD-1002", customer: "Michael Brown", total: 89.5 },
];
const fromCache: Order[] = [
{ id: "ORD-1002", customer: "Michael Brown", total: 95.0 }, // same id, newer total
{ id: "ORD-1003", customer: "Olivia Davis", total: 430 },
];
// a patch only carries the fields that changed
type OrderPatch = Partial<Order> & { id: string };
const patches: OrderPatch[] = [
{ id: "ORD-1001", total: 260 },
{ id: "ORD-9999", total: 5 }, // an id that does not exist yet
];
function applyPatches(orders: readonly Order[], updates: readonly OrderPatch[]): Order[] {
const byId = new Map(orders.map(order => [order.id, order]));
for (const patch of updates) {
const current = byId.get(patch.id);
if (current) byId.set(patch.id, { ...current, ...patch }); // update
else console.log("no order for", patch.id, "- skipped"); // or insert, if you prefer
}
return [...byId.values()];
}
console.log(applyPatches(fromApi, patches).map(o => `${o.id} $${o.total}`));
Output:
no order for ORD-9999 - skipped
[ 'ORD-1001 $260', 'ORD-1002 $89.5' ]
Flattening arrays of arrays
Paged API responses arrive as an array of pages. flat() turns them into one list, and flatMap() does the mapping and the flattening in one pass, which is the tidy way to pull line items out of orders:
interface Item {
sku: string;
}
const pages: Item[][] = [
[{ sku: "KB-1" }, { sku: "MN-9" }],
[{ sku: "WC-3" }],
[],
];
// one array out of many: flat() or spreading
console.log(pages.flat().map(i => i.sku));
console.log(([] as Item[]).concat(...pages).length, "items with concat");
// map and flatten in one step
const orders = [
{ id: "ORD-1001", items: [{ sku: "KB-1" }, { sku: "MN-9" }] },
{ id: "ORD-1002", items: [{ sku: "WC-3" }] },
];
console.log(orders.flatMap(order => order.items).map(i => i.sku));
// deeper nesting needs a depth, or Infinity
const deep = [1, [2, [3, [4]]]];
console.log(JSON.stringify(deep.flat()), JSON.stringify(deep.flat(Infinity)));
Output:
[ 'KB-1', 'MN-9', 'WC-3' ]
3 items with concat
[ 'KB-1', 'MN-9', 'WC-3' ]
[1,2,[3,[4]]] [1,2,3,4]
flat() goes one level by default; pass a depth or Infinity for nested data.Merging arrays of different shapes
Merging an Order[] with a Note[] gives you (Order | Note)[], and TypeScript then makes you check which one you have before using it. A discriminant field such as kind makes that check a one-liner, the same way it does when filtering a union of shapes:
interface Order {
kind: "order";
id: string;
}
interface Note {
kind: "note";
text: string;
}
const orders: Order[] = [{ kind: "order", id: "ORD-1001" }];
const notes: Note[] = [{ kind: "note", text: "called back" }];
// merging different shapes gives an array of the union
const feed: (Order | Note)[] = [...orders, ...notes];
for (const row of feed) {
console.log(row.kind === "order" ? `order ${row.id}` : `note: ${row.text}`);
}
// a generic helper that keeps whatever types it was given
function mergeAll<T>(...arrays: readonly T[][]): T[] {
return arrays.flat();
}
console.log(mergeAll([1, 2], [3], []).length, mergeAll(["a"], ["b"]).join(""));
Output:
order ORD-1001
note: called back
3 ab
Do not call concat() in a loop
concat() builds a new array every time it is called, so merging a thousand batches copies the whole result a thousand times. push(...batch) adds into the array you already have, and flat() does the lot in one call:
const batches: number[][] = Array.from({ length: 1_000 }, (_, b) =>
Array.from({ length: 100 }, (_, i) => b * 100 + i),
);
function time(label: string, fn: () => number): void {
fn(); // warm-up run
const start = performance.now();
const size = fn();
console.log(label.padEnd(28), (performance.now() - start).toFixed(1).padStart(8), "ms", "rows", size);
}
time("concat in a loop", () => {
let all: number[] = [];
for (const batch of batches) all = all.concat(batch); // a new array every time
return all.length;
});
time("push(...batch)", () => {
const all: number[] = [];
for (const batch of batches) all.push(...batch);
return all.length;
});
time("flat()", () => batches.flat().length);
concat() calls is the slow one.Keep push(...batch) for batches of a few thousand at most, because spreading passes one argument per element and the call has a limit, as noted in adding elements to an array.
More TypeScript array guides worth reading:
- Arrays of objects in TypeScript
- Remove duplicate objects from an array
- Add or append elements to an array
- Sort an array of objects by property
- Filter an array of objects
Frequently asked questions
How do I merge two arrays in TypeScript?
a.concat(b) or [...a, ...b]. Both return a new array; use a.push(...b) to add into an existing one.
How do I merge arrays of objects by id?
[...new Map([...a, ...b].map(o => [o.id, o])).values()]. Later entries overwrite earlier ones, so order decides the winner.
What is the difference between concat() and spread?
Nothing meaningful for arrays. concat() also accepts single values, and spread reads better when merging several arrays.
Why does merging my objects lose nested fields?
Object spread is shallow, so a nested object replaces the original. Spread the nested level too: { ...a.meta, ...b.meta }.
How do I combine an array of arrays?
arrays.flat(), or items.flatMap(fn) when you are mapping and flattening at the same time.
How do I merge arrays with different object types?
The result is a union array, (A | B)[]. Add a shared field such as kind so you can tell the rows apart safely.
Is concat() slow?
Only in a loop, where it copies the whole array each time. Use push(...batch) or flat() to build a large list.
Bijay Kumar is a 13-time Microsoft MVP with more than 18 years in software development, and the founder of Python Guides and TSinfo Technologies. He started out building .NET and SharePoint solutions at HP, TCS and KPIT before moving into Python, machine learning and AI, and he also builds web apps with TypeScript and React. He writes the tutorials here himself, and every example is run before publishing so you see the real output. More about Bijay · Microsoft MVP profile · LinkedIn