Skip to the code
cf.

sortBy with several keys

Write sortBy(array, criteria). It returns a new array with the items of array, sorted. array itself never changes. criteria is an array of sort keys, the most important first.

Each criterion is a property name, a function that takes an item and returns its sort value, or an object { key, order }. In the object form, key is a property name or a function, and order is "asc" (the default) or "desc".

Items are compared by the first criterion. When they tie, the next criterion decides, and so on. Items that tie on every criterion keep their original order, in both directions: the sort is stable. With no criteria, the result is a copy in the original order.

Sort values are numbers, strings, or dates; compare them with < and >. Two values tie when neither is smaller than the other, so two dates with the same time tie.

null and undefined values always go last, whatever the order, and they tie with each other.

A key function may be slow, so call each one at most once per item.

const people = [
  { name: "Cy", age: 30 },
  { name: "Ada", age: 36 },
  { name: "Bo", age: 30 },
  { name: "Di", age: null },
];
 
sortBy(people, ["age"]).map((p) => p.name);
// ["Cy", "Bo", "Ada", "Di"]: Cy and Bo tie, so they keep their order
 
sortBy(people, [{ key: "age", order: "desc" }, "name"]).map((p) => p.name);
// ["Ada", "Bo", "Cy", "Di"]: Di's null age goes last, even in "desc"
 
sortBy(people, [(p) => p.name.length]).map((p) => p.name);
// ["Cy", "Bo", "Di", "Ada"]

What the tests check

  1. sorts by a property, ascending
    expect(sortBy([{ v: 10 }, { v: 9 }, { v: 100 }], ["v"]).map((x) => x.v)).toEqual([9, 10, 100]);
    expect(sortBy([{ name: "Cy" }, { name: "Ada" }, { name: "Bo" }], ["name"]).map((x) => x.name)).toEqual(["Ada", "Bo", "Cy"]);
    
  2. sorts by a key function
    expect(sortBy(["ccc", "a", "bb"], [(s) => s.length])).toEqual(["a", "bb", "ccc"]);
    expect(sortBy([{ a: 1, b: 5 }, { a: 2, b: 1 }], [(x) => x.a + x.b]).map((x) => x.a)).toEqual([2, 1]);
    
  3. breaks ties with the next criterion
    const people = [
      { name: "Cy", age: 30 },
      { name: "Ada", age: 36 },
      { name: "Bo", age: 30 },
      { name: "Al", age: 30 },
    ];
    expect(sortBy(people, ["age", "name"]).map((p) => p.name)).toEqual(["Al", "Bo", "Cy", "Ada"]);
    
  4. sorts one criterion in descending order with order: "desc"
    const people = [
      { name: "Cy", age: 30 },
      { name: "Ada", age: 36 },
      { name: "Bo", age: 30 },
      { name: "Al", age: 25 },
    ];
    expect(sortBy(people, [{ key: "age", order: "desc" }, "name"]).map((p) => p.name)).toEqual(["Ada", "Bo", "Cy", "Al"]);
    expect(sortBy(["bb", "a", "ccc"], [{ key: (s) => s.length, order: "desc" }])).toEqual(["ccc", "bb", "a"]);
    expect(sortBy([{ v: 2 }, { v: 1 }], [{ key: "v", order: "asc" }]).map((x) => x.v)).toEqual([1, 2]);
    
  5. keeps the original order of full ties, in both directions
    const items = [
      { group: 2, id: "a" },
      { group: 1, id: "b" },
      { group: 2, id: "c" },
      { group: 1, id: "d" },
      { group: 2, id: "e" },
    ];
    expect(sortBy(items, ["group"]).map((x) => x.id)).toEqual(["b", "d", "a", "c", "e"]);
    expect(sortBy(items, [{ key: "group", order: "desc" }]).map((x) => x.id)).toEqual(["a", "c", "e", "b", "d"]);
    
  6. puts null and undefined last in both orders
    const items = [{ v: 2 }, { v: null }, { v: 1 }, {}, { v: 3 }];
    expect(sortBy(items, ["v"]).map((x) => x.v)).toEqual([1, 2, 3, null, undefined]);
    expect(sortBy(items, [{ key: "v", order: "desc" }]).map((x) => x.v)).toEqual([3, 2, 1, null, undefined]);
    const missing = [{ v: undefined, id: "b" }, { v: null, id: "a" }, { v: 0, id: "c" }];
    expect(sortBy(missing, ["v", "id"]).map((x) => x.id)).toEqual(["c", "a", "b"]);
    
  7. compares dates by time, and equal dates tie
    const events = [
      { at: new Date(2024, 0, 2), id: "x" },
      { at: new Date(2024, 0, 1), id: "y" },
      { at: new Date(2024, 0, 2), id: "a" },
      { at: new Date(2024, 0, 2), id: "m" },
    ];
    expect(sortBy(events, ["at", "id"]).map((e) => e.id)).toEqual(["y", "a", "m", "x"]);
    expect(sortBy(events, [{ key: "at", order: "desc" }, "id"]).map((e) => e.id)).toEqual(["a", "m", "x", "y"]);
    
  8. calls each key function at most once per item
    const items = Array.from({ length: 40 }, (_, i) => ({ group: (i * 7) % 5, id: i }));
    const byGroup = spy((item) => item.group);
    const byId = spy((item) => item.id);
    const sorted = sortBy(items, [byGroup, { key: byId, order: "desc" }]);
    expect(byGroup.callCount).toBeLessThanOrEqual(40);
    expect(byId.callCount).toBeLessThanOrEqual(40);
    expect(sorted.slice(0, 3).map((x) => x.id)).toEqual([35, 30, 25]);
    expect(sorted.map((x) => x.group)).toEqual([...items.map((x) => x.group)].sort((x, y) => x - y));
    
  9. returns a new array and leaves the original alone
    const items = [{ n: 3 }, { n: 1 }, { n: 2 }];
    const sorted = sortBy(items, ["n"]);
    expect(sorted).not.toBe(items);
    expect(items.map((x) => x.n)).toEqual([3, 1, 2]);
    const copy = sortBy(items, []);
    expect(copy).not.toBe(items);
    expect(copy.map((x) => x.n)).toEqual([3, 1, 2]);
    

Learn more

solution.jsSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).