, ,

Sorting Arrays in JavaScript Without Bugs

Posted by

A practical guide to writing correct, fast, and readable sorts in JavaScript — with copy-paste utilities you’ll reuse forever.

A practical guide to writing correct, fast, and readable sorts in JavaScript — with copy-paste utilities you’ll reuse forever.

Introduction

Sorting is one of those “it should be simple” tasks that quietly spawns bugs:

  • Numbers accidentally sorted as strings ([2,10,1] → [1,10,2]).
  • UIs re-rendering weirdly because sort() mutated shared state.
  • Inconsistent comparators causing “Heisenbugs” (order changes across runs).
  • Diacritics and locales breaking A–Z expectations.
  • null/undefined values jumping around between deploys.

This guide shows how to sort without surprises. We’ll cover the rules the engine follows, safe comparator patterns, immutability, locale-aware string sorts, multi-key sorts, nulls-last, stable sorting, and performance tricks (Schwartzian transform, Intl.Collator). Keep it handy as your sorting checklist.


1) First Principles: How Array.prototype.sort works

  • Default behavior (no compare function): converts items to strings and compares by UTF-16 code units (lexicographical).
[2, 10, 1].sort(); // ["1","10","2"] → [1, 10, 2]  ❌ not numeric
  1. With a comparator: pass a function (a, b) => number returning:
  • < 0a comes before b
  • > 0a comes after b
  • 0 → considered equal (important for stability)

2. In-place: sort() mutates the array.

3. Stable: modern JS requires a stable sort (items that compare equal keep their relative order).

3. Sparseness: “holes” (missing indices) are treated as undefined and moved to the end with the default sort.

Rule of thumb: Always provide a comparator unless you truly want lexicographic string ordering.


2) Don’t Mutate Shared State: prefer toSorted

If you’re in React/solid state or any code that relies on immutability, avoid sort() mutating in place.

  1. Non-mutating modern method:
const sorted = arr.toSorted((a, b) => a - b); // returns a new array

2. Fallback for older runtimes:

const sorted = [...arr].sort((a, b) => a - b);

Checklist

  • UI/state code → use toSorted / copy-then-sort.
  • Algorithmic / local arrays → sort() is fine.

3) Numbers: the classic “gotcha” and the right way

Numeric ascending

const nums = [2, 10, 1];
nums.toSorted((a, b) => a - b); // [1, 2, 10]

Numeric descending

nums.toSorted((a, b) => b - a);

BigInt safe (avoid subtraction)

const bigs = [2n, 10n, 1n];
bigs.toSorted((a, b) => (a < b ? -1 : a > b ? 1 : 0));

Handling NaN (place NaN at the end)

const numeric = (a, b) => {
const an = Number(a), bn = Number(b);
const aNaN = Number.isNaN(an), bNaN = Number.isNaN(bn);
if (aNaN && bNaN) return 0;
if (aNaN) return 1; // NaN last
if (bNaN) return -1;
return an - bn;
};

4) Strings: ASCII vs humans (use Intl.Collator)

Lexicographic by code units (fast, but not human-friendly):

names.toSorted(); // "Z" < "a", "Å" sorts oddly, accents ignored

Human-friendly collation:

const collator = new Intl.Collator('en', { sensitivity: 'base' });
// base → ignore case/accents for primary ordering
names.toSorted((a, b) => collator.compare(a, b));

Case-insensitive without Intl (OK for simple English, not full i18n):

names.toSorted((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));

Tip: If you sort strings often, reuse the Intl.Collator instance for speed. Creating it per compare call is slow.


5) Objects: sort by one or multiple keys

By one key

const byKey = key => (a, b) => {
const x = a[key], y = b[key];
return x < y ? -1 : x > y ? 1 : 0;
};

users.toSorted(byKey('age'));

By multiple keys (primary, then tie-break)

const by = (...comparators) => (a, b) => {
for (const cmp of comparators) {
const r = cmp(a, b);
if (r !== 0) return r;
}
return 0;
};

const asc = key => (a, b) => (a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0);
const desc = key => (a, b) => (a[key] > b[key] ? -1 : a[key] < b[key] ? 1 : 0);

const collator = new Intl.Collator('en', { sensitivity: 'base' });
const strAsc = key => (a, b) => collator.compare(a[key] ?? '', b[key] ?? '');

users.toSorted(by(
strAsc('lastName'), // A–Z last name
strAsc('firstName'), // then first name
desc('score') // highest score first for ties
));

Nested paths

const byPath = (...path) => (a, b) => {
const va = path.reduce((o,k)=>o?.[k], a);
const vb = path.reduce((o,k)=>o?.[k], b);
return va < vb ? -1 : va > vb ? 1 : 0;
};

orders.toSorted(byPath('customer', 'address', 'city'));

6) Put null/undefined at the end (or beginning)

“Nulls last” is a common requirement.

const nullsLast = cmp => (a, b) => {
const aNull = a == null, bNull = b == null;
if (aNull && bNull) return 0;
if (aNull) return 1;
if (bNull) return -1;
return cmp(a, b);
};

// Example: sort by price ascending, nulls last
const byPrice = (a, b) => a.price - b.price;
products.toSorted((a, b) => nullsLast(byPrice)(a, b));

Works the same for key selectors: check a[key] == null.


7) Comparator correctness: avoid “random” results

A comparator must be:

  • Antisymmetric: cmp(a,b) = -cmp(b,a)
  • Transitive: if cmp(a,b) < 0 and cmp(b,c) < 0 then cmp(a,c) < 0
  • Reflexive to zero: cmp(a,a) = 0

Bad (inconsistent) comparator

// ❌ randomizes order when scores equal because it never returns 0
const bad = (a, b) => (a.score > b.score ? -1 : 1);

Good comparator

const good = (a, b) => (a.score > b.score ? -1 : a.score < b.score ? 1 : 0);

When equal, return 0. Stability then guarantees original order for ties.


8) Performance: sort once, compute keys once

If your key extraction is expensive (e.g., toLowerCase(), parsing dates, computing ranks), use the Schwartzian transform (“decorate → sort → undecorate”).

// Decorate
const decorated = items.map(item => ({
item,
key: item.name.toLocaleLowerCase(), // compute once
}));

// Sort by precomputed key
decorated.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));

// Undecorate
const result = decorated.map(d => d.item);

This avoids recomputing the key O(n log n) times.

Other perf notes

  • Reuse Intl.Collator (don’t new it inside comparator).
  • Prefer simple comparisons over localeCompare if you don’t need i18n.
  • Sorting is O(n log n); cutting work inside each comparison saves a lot overall.
  • For “already almost sorted” data, modern engines are quite efficient (TimSort-like), but correctness still comes first.

9) Dates and times (no more string traps)

By timestamp (fast & correct)

events.toSorted((a, b) => new Date(a.when) - new Date(b.when));

If you already have Date objects:

events.toSorted((a, b) => a.date - b.date);

Avoid string compare like "2024-2-10" < "2024-12-01" (string format must be ISO with zero-padding to be safe).


10) Sorting booleans, enums, and custom orders

Booleans (false first, true last)

items.toSorted((a, b) => Number(a.flag) - Number(b.flag));

Custom order (e.g., status lanes)

const order = new Map([['todo', 0], ['doing', 1], ['done', 2]]);
const byStatus = (a, b) => order.get(a.status) - order.get(b.status);
tasks.toSorted(byStatus);

11) Stable multi-pass sorts (when you don’t want a comparator)

Because sort is stable, you can do secondary → primary in two passes:

// sort by firstName, then lastName (primary)
users
.toSorted((a, b) => a.first.localeCompare(b.first))
.toSorted((a, b) => a.last.localeCompare(b.last));

The second sort preserves ties by last using the prior first order.
 Great for quick scripts, but for production prefer a single combined comparator (less work).


12) Dealing with mixed types

When arrays contain mixed types (numbers, strings, null, objects), define a type order then apply per-type comparison.

const typeRank = v =>
v == null ? 3 :
typeof v === 'number' ? 0 :
typeof v === 'string' ? 1 :
2; // objects/others

const mixedCompare = (a, b) => {
const ra = typeRank(a), rb = typeRank(b);
if (ra !== rb) return ra - rb;
// same type: compare within type
if (typeof a === 'number') return a - b;
if (typeof a === 'string') return a.localeCompare(b, undefined, { sensitivity: 'base' });
return 0; // or add more rules for objects
};
arr.toSorted(mixedCompare);

13) Practical utilities (copy-paste)

A. Simple comparator builders

export const asc  = sel => (a, b) => {
const x = sel(a), y = sel(b);
return x < y ? -1 : x > y ? 1 : 0;
};

export const desc = sel => (a, b) => {
const x = sel(a), y = sel(b);
return x > y ? -1 : x < y ? 1 : 0;
};

export const by = (...cmps) => (a, b) => {
for (const c of cmps) { const r = c(a, b); if (r) return r; }
return 0;
};

B. Nulls-last wrapper

export const nullsLast = cmp => (a, b) => {
const aN = a == null, bN = b == null;
if (aN && bN) return 0;
if (aN) return 1;
if (bN) return -1;
return cmp(a, b);
};

C. Collator-based string comparators

const coll = new Intl.Collator(undefined, { sensitivity: 'base' });

export const strAsc = sel => (a, b) => coll.compare(sel(a) ?? '', sel(b) ?? '');
export const strDesc = sel => (a, b) => coll.compare(sel(b) ?? '', sel(a) ?? '');

D. Key extraction once (Schwartzian)

export function sortByKeyOnce(items, keyFn, cmp = asc(x => x.key)) {
return items
.map(item => ({ item, key: keyFn(item) }))
.sort(cmp)
.map(d => d.item);
}

E. Numeric, guarding NaN

export const numAsc = sel => (a, b) => {
const x = Number(sel(a)), y = Number(sel(b));
const xN = Number.isNaN(x), yN = Number.isNaN(y);
if (xN && yN) return 0;
if (xN) return 1;
if (yN) return -1;
return x - y;
};

14) Testing your sorts (prevent regressions)

  • Equal items remain in order (stability):
const arr = [{k:1,i:0},{k:1,i:1},{k:2,i:2}];
const out = arr.toSorted((a,b)=>a.k-b.k);
// expect out.map(x=>x.i) toEqual [0,1,2]
  • No mutation when you promise immutability:
const original = [3,1,2];
const out = original.toSorted((a,b)=>a-b);
// expect(original).toEqual([3,1,2])
// expect(out).toEqual([1,2,3])
  • Nulls-last behavior:
const out = [null, 1, 0, undefined].toSorted((a,b)=>nullsLast((x,y)=>x-y)(a,b));
// expect(out).toEqual([0,1,null,undefined])
  • Locale correctness with accents/case (if you rely on it).

15) Common bugs (and quick fixes)


16) Recipes you’ll actually reuse

Sort users by last, then first (case/diacritics-insensitive)

const coll = new Intl.Collator('en', { sensitivity: 'base' });
const usersSorted = users.toSorted((a, b) =>
by(
(x, y) => coll.compare(x.lastName, y.lastName),
(x, y) => coll.compare(x.firstName, y.firstName)
)(a, b)
);

Sort products by price asc, with “price N/A” at the end

const priceAsc = (a, b) => (a.price ?? Infinity) - (b.price ?? Infinity);
products.toSorted(priceAsc);

Sort tasks by custom status lane, then due date

const order = new Map([['todo',0],['doing',1],['review',2],['done',3]]);
const byStatus = (a, b) => order.get(a.status) - order.get(b.status);
const byDue = (a, b) => new Date(a.due) - new Date(b.due);

tasks.toSorted(by(byStatus, byDue));

Sort large list by case-folded name, key computed once

const result = sortByKeyOnce(people, p => p.name.toLocaleLowerCase());

Conclusion

Sorting in JavaScript is easy to write — and easy to get wrong. The “no bugs” approach boils down to:

  1. Always provide a comparator unless you truly want lexicographic string order.
  2. Don’t mutate shared state: prefer toSorted (or copy first).
  3. Use the right comparator for the data:
  • Numbers → (a,b)=>a-b
  • Strings for humans → Intl.Collator
  • Objects → comparator builders and by(...)
  • Nullish handling → nullsLast/nullsFirst

4. Return 0 for equal values to leverage stability.

5. Optimize by computing keys once and reusing Intl.Collator.

Adopt these patterns and you’ll stop shipping “mystery order” bugs, make your code easier to read, and keep performance solid — even on big lists.


Call to Action

What sorting bug bit your team most recently — mutating sort(), diacritics ordering, or tie-breaks missing?
💬 Drop the story (and data shape) in the comments and I’ll suggest a tailored comparator.
🔖 Bookmark this as your sorting playbook.
👩‍💻 Share with a teammate who still sorts numbers without a comparator.

Leave a Reply

Your email address will not be published. Required fields are marked *