TypeScript Sorting and Custom Comparators: The Interview Cheat Sheet

May 29, 20269 min read
dsaalgorithmsinterview-prepleetcode
TypeScript Sorting and Custom Comparators: The Interview Cheat Sheet
TL;DR
  • Default sort is lexicographic: without a comparator, Array.sort() coerces elements to strings, so [10, 2, 1] sorts as [1, 10, 2]
  • Comparator return value: negative = a first, positive = b first, zero = equal. Anchor to a - b subtraction so you never misremember under pressure
  • Stable since ES2019: equal-key elements preserve relative order on Node 18+; you can rely on this in LeetCode
  • Sort mutates in place: copy with [...arr].sort(cmp) or use arr.toSorted(cmp) (Node 20+) to preserve the original array
  • undefined skips your comparator: spec moves undefined elements to the end regardless of your comparison function
  • Multi-key shorthand: (a, b) => cmp1(a, b) || cmp2(a, b) exploits falsy zero to fall back to a secondary sort key automatically
  • Subtraction traps: never subtract strings (returns NaN), and guard sentinel values against Infinity - Infinity

You know Array.sort() takes a comparator. You've written (a, b) => a - b enough times it lives in muscle memory. What you probably cannot do under interview pressure is explain what the return value actually means, name the four ways the default silently destroys you, or write a stable multi-key sort without second-guessing yourself. Somewhere out there, a bug report is quietly loading because someone skipped the comparator. This is the reference.

The Default Sort Will Betray You

Here is the trap that burns more candidates than almost any other sorting question:

const nums = [10, 2, 1, 20, 3]; console.log(nums.sort()); // [1, 10, 2, 20, 3]

Take a second with that output. It is not a glitch. It is working exactly as intended. Without a comparator, Array.prototype.sort coerces every element to a string and compares by UTF-16 code units. So 10 becomes "10", and "10" < "2" because the character "1" has a lower code point than "2". Numbers sort lexicographically, not numerically. The bug is completely silent. Your test case with [1, 2, 3] passes. The one with [1, 2, 10] does not. Prod will let you know.

This is not a TypeScript quirk. It is intentional per the ECMAScript specification. TypeScript adds types on top of the same runtime, so it will not save you. The typescript-eslint rule require-array-sort-compare exists precisely because this bug is common enough that someone wrote a linter to yell at developers about it automatically.

Always pass a comparator. Always.

How TypeScript Sort Comparators Work

The comparator (a, b) => number encodes a comparison with three outcomes:

Return valueMeaning
Negativea comes before b
Positiveb comes before a
ZeroTreat as equal, preserve relative order

Anchor it to subtraction: the return value is a - b on an imaginary number line. Negative means a is smaller, so it goes left. A lot of engineers memorize "negative means a first" without the reasoning, then blank on it mid-interview when the pressure hits. The subtraction model sticks.

A comparator must be consistent: if cmp(a, b) < 0, then cmp(b, a) must be > 0. Violate transitivity and the engine is allowed to produce any order. In practice, the result depends on the sort algorithm's internal access pattern, which means it looks random and changes between browser versions. Great way to get a Heisenbug.

Since ECMAScript 2019, the sort must be stable. V8 switched to TimSort in Chrome 70 and Node.js 11, so equal-key elements preserve their original relative order. On LeetCode (Node 18+), you can rely on stability. For the full story on what stability means and when it matters, see Stable Sort: The Property That Makes Multi-Key Sorting Actually Work.

The Five Patterns That Cover Every Interview Problem

Numbers, ascending and descending

const asc = nums.sort((a, b) => a - b); const desc = nums.sort((a, b) => b - a);

The subtraction trick works because JavaScript numbers are 64-bit floats with no integer overflow in the traditional sense. One edge case: NaN. If either element is NaN, the subtraction returns NaN, which the engine treats as 0 and quietly scrambles the result. If your input can contain NaN, use explicit comparison.

Strings

const words = ["banana", "Apple", "cherry"]; words.sort((a, b) => a.localeCompare(b)); // case-insensitive, locale-aware words.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); // byte order, fast

localeCompare handles Unicode and case correctly. The explicit ternary is faster for ASCII-only data. Never use a - b on strings. Subtracting two strings returns NaN, which you now know is bad.

Objects by a single property

interface Task { name: string; priority: number; } tasks.sort((a, b) => a.priority - b.priority); tasks.sort((a, b) => a.name.localeCompare(b.name));

Multi-key sort

Sort by the primary key, break ties with a secondary:

tasks.sort((a, b) => { const byPriority = a.priority - b.priority; if (byPriority !== 0) return byPriority; return a.name.localeCompare(b.name); });

This is the pattern for interval sorting, coordinate sorting, and any "sort by X, break ties by Y" problem. Those come up constantly.

Frequency sort

const freq = new Map<number, number>(); for (const n of nums) freq.set(n, (freq.get(n) ?? 0) + 1); nums.sort((a, b) => (freq.get(b) ?? 0) - (freq.get(a) ?? 0));

Build the frequency map first, then sort by it. The ?? 0 guard handles TypeScript's strict null checks for elements that theoretically might not be in the map. In an interview you will be tempted to skip it. TypeScript will make you pay.

The Traps That Actually Fail Candidates

Trap 1: Subtraction with non-finite values

const vals = [Infinity, -Infinity, 5, 3]; vals.sort((a, b) => a - b); // fine here // But Infinity - Infinity = NaN. Watch for sentinel values like Number.MAX_SAFE_INTEGER.

If a problem uses sentinels, use explicit comparison. The subtraction shorthand is 90% reliable and silently wrong the other 10%. That 10% has a habit of hiding in the one test case your interviewer is watching closely.

Trap 2: Sort mutates the original array

This one has a special cruelty because the bug is invisible until it matters.

const original = [3, 1, 2]; const sorted = original.sort((a, b) => a - b); console.log(original); // [1, 2, 3], original is gone console.log(sorted === original); // true, same reference

Array.sort sorts in place and returns the same array. The return value is not a copy. It is a reference to the array you just destroyed. If you need to keep the original, copy first:

const sorted = [...original].sort((a, b) => a - b); // or const sorted = original.slice().sort((a, b) => a - b);

ES2023 added Array.prototype.toSorted(), which returns a new sorted array without touching the original. Available in Node.js 20+ and modern browsers, but not guaranteed on LeetCode. Safe bet: use the spread copy.

Trap 3: Using a random comparator as a shuffle

// This is NOT a correct shuffle arr.sort(() => Math.random() - 0.5);

The comparator must be consistent between calls. A random comparator violates transitivity, and the resulting distribution is biased by the sort algorithm's access pattern. You will get some permutations far more often than others, and there is no easy way to tell which ones. For a correct shuffle, use Fisher-Yates. The difference matters more than you would expect.

Trap 4: undefined ignores your comparator entirely

Per spec, undefined elements are always moved to the end of the array, and the comparator is never called with them. null is not special: it gets coerced to the string "null" by the default sort and passed to your comparator like any other value. Know the difference. It has appeared in interviews, and explaining it well signals you understand the runtime, not just the syntax.

Where TypeScript Actually Helps

TypeScript catches the obvious type error at compile time:

// TypeScript error: operator '-' cannot be applied to type 'string' words.sort((a, b) => a - b);

You can also write a reusable typed comparator factory:

type Comparator<T> = (a: T, b: T) => number; function byKey<T, K extends keyof T>(key: K): Comparator<T> { return (a, b) => { const av = a[key]; const bv = b[key]; if (typeof av === "number" && typeof bv === "number") return av - bv; return String(av).localeCompare(String(bv)); }; } tasks.sort(byKey("priority"));

In a timed interview, you probably will not write a factory. But knowing the type signature (a: T, b: T) => number is enough to write clean, readable comparators inline without hesitation. For a broader cheat sheet covering TypeScript's collections and runtime traps, see TypeScript for Coding Interviews.

Quick Reference

GoalComparator
Numbers ascending(a, b) => a - b
Numbers descending(a, b) => b - a
Strings ascending(a, b) => a.localeCompare(b)
Strings descending(a, b) => b.localeCompare(a)
Objects by numeric field(a, b) => a.field - b.field
Objects by string field(a, b) => a.field.localeCompare(b.field)
Multi-key (primary, fallback)`(a, b) => (cmp1(a, b)
Non-mutating sort[...arr].sort(cmp) or arr.toSorted(cmp) (Node 20+)

The || shorthand for multi-key works because 0 is falsy. When the first comparator returns 0 (a tie), the second runs automatically. Two lines instead of five. Take it.

Narrate the Sort

Interviewers probe sorting two ways. First: "sort this array of intervals by start time," which is direct and obvious. Second: sorting is preprocessing before the real algorithm, and you have to justify why.

When sorting is preprocessing, say so out loud. "I'm going to sort by start time because it lets me apply a greedy scan." The interviewer wants to know you understand the O(n log n) cost and why it unlocks your approach. Sorting is not a free black box in an interview. The JavaScript for Coding Interviews guide covers the broader set of missing data structures and gotchas that apply equally in TypeScript.

Narrating while you code is its own skill, separate from knowing comparators cold. SpaceComplexity runs voice-based mock interviews with rubric feedback across communication, problem-solving, and code quality, so you can rehearse the explanation, not just the code.

Further Reading