Merge Arrays of Objects in TypeScript (concat, spread, by id)

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
Command Prompt showing two TypeScript arrays of order objects combined with concat, spread and push
Four rows either way, with concat() and spread leaving the sources untouched.
What you wantUse
A new combined arraya.concat(b) or [...a, ...b]
Add into an existing arraya.push(...b)
One row per id, later winsnew Map([...a, ...b].map(o => [o.id, o]))
One row per id, first winsonly set ids you have not seen
Fields from both rows{ ...current, ...incoming }
Many arrays into onearrays.flat()
Map and flatten togetheritems.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'
]
Command Prompt output showing merged orders where the cached total wins, then where the API total wins, then both versions combined
Same two arrays, three merge rules: last wins, first wins, or combine the fields.

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}}
Command Prompt output showing that spreading a saved settings object replaces the nested notifications object entirely until it is merged explicitly
Line 1 lost 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]
Command Prompt output of flat and flatMap combining pages of items and pulling line items out of orders, plus a deep flatten
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);
Command Prompt output timing concat inside a loop against push with spread and flat for merging one thousand arrays
Same 100,000 rows: the loop of 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:

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.