The Principle Of Least Astonishment 😲 and JavaScript’s .sort()

When we write code, we should take a moment to consider, “If someone else was reading this for the first time, what would they expect?
A child holding a book, with a shocked face.
https://unsplash.com/photos/qDY9ahp0Mto — Astonishing code can make us all feel a bit less confident in our abilities.

Take the following piece of code — please take a moment to really consider, what you expect the output to be before you run it. I ask this, even if you are not a JavaScript developer.

Despite being a seemingly simple piece of code — only 4 lines, 3 function calls, and only one “weird” bit of syntax on line 4, there is a large amount going on in this snippet.

I asked a few colleagues, from junior to senior levels, with varying experience in either JavaScript or C++ (from 1 year to 10+), and all were tripped up in a different way by this snippet.

🔍 Before running, let’s examine our assumptions

What are some of the possible points of confusion that they encountered here? These are thoughts my colleagues mentioned and expected before running the code — not what the actual behaviour is.

  • “You used const to declare A, yet you clearly mutate it on line 4.”
  • .push makes a copy of the array and adds the value to the end.”
  • .push makes a copy of the array and adds the value to the beginning.”
  • “If .sort is mutating in place, it wouldn’t return anything, definitely - not an array.”
  • “If .sort returns an array, it must return a new copy, right?”
  • “How does .sort work? 18 is clearly larger than 2! It must be broken.”
  • “If .sort were sorting by strings, then I would expect “18” to be larger than “2” as it has more characters in it.”
  • “Mutating A on line 4 should not also mutate B.”
  • A, B and C are all the same array.”
  • C would be the value we pushed, 9.”

👟 Running the code

Running the code, you’ll see the following output:

A:  [ 2, 18, 2, 3, 9 ]
B:  [ 2, 18, 2, 3, 9 ]
C:  5

Is this surprising to you?

Let’s break this down in several places. This result doesn’t come from the fact it’s particularly complex code, or that any one line is specifically confusing. It comes from a combination of many, seemingly ambiguous or not entirely clear interfaces working together to confuse readers.

It’s a great example of how the Principle Of Least Astonishment is important to keep in mind when designing libraries, languages and systems to be consumed by other people.

  1. ️Let’s start with how .sort() and JavaScript arrays work

JavaScript arrays can be mildly confusing to those who come from strongly typed languages like C++ or Java.

In C++, your array must be of a specific type, say int, and trying to place a bool into it will cause a compile time error. JavaScript has no such qualms: The following is a valid array:

const myArray = [6, "hello", { name: "Steve!" }, [3, 2], function() { return "Hi";} ];

Note how myArray here contains a number, a string, an object, another array, and even a function! How would you possibly sort this array?

Well, let’s call .sort() on it and find out! The result is this:

[[3, 2], 6, { name: "Steve!" }, function() { return "Hi"; }, "hello"]

No, this was not randomly jumbled together — it’s all to do with the comparison function. See, .sort() actually takes a parameter on how to compare two values. It’s just that if none is given, the default comparison function looks somewhat like this:

function(left, right) { return String(left) > String(right); }

By default, JavaScript’s sort converts values to strings before sorting.

This might seem odd — if we have an array full of numbers, why convert to a string before comparing? The answer is simple — JavaScript doesn’t know that it’s an array of numbers. The array could have any value in it, so converting to string first is the simplest solution.

Functions are converted to strings as if their original JS code was a string — so the function in our array would convert to the following string:

String(function() { return "Hi"; }) === "function() { return \"Hi\"; }"

Arrays within the array will receive similar but not exactly the same treatment. They will be flattened, then square brackets will be removed. As such:

String([3, 2, [1, "a"]]) === "3,2,1,a"

Objects act in a different way still:

String({name: "Topher", {height: "180", weight: "100"}}) === "[object Object]"

I won’t go any further into why this is or other implications of this conversion to string here, but the key takeaway is that everything in JavaScript can be converted to a string, so any array can be sorted.

This explains why 18 is considered smaller than 2: the string "18" is considered smaller than the string "2". The comparison will check character by character, and as "1" < "2" , the "8" isn’t even checked.

Luckily it’s very easy to tell an array to sort numerically:

A.sort(function(a, b) { return a - b; })

  1. Mutation is confusing when not clearly signalled

.sort() mutates the array in place, and then returns it 🤢

This means that after calling const B = A.sort(), the original variable A has changed to be sorted, but also now that B === A. B is not a safe new copy of A either — it is literally the same thing.

Converting to strings before sorting is confusing but justified, but mutating an object in place and returning it is a cardinal sin. (Fluent builder objects are an exception here.)

Many of my colleagues disagreed on whether sort should return a brand new sorted copy of the array, or sort the array in place and return nothing. None of them expected or suggested it should do both.

I want to make this point again as it’s so important and key to the message of this article:

Please do not simultaneously mutate and return this in the same function: it is astonishing and unexpected.

If a developer believes your function will act in one way, and doesn’t actively check it doesn’t act in the other, they will believe their assumption was correct and continue on, potentially causing issues later down the line.

  1. JavaScript is pass by value-of-address

Asking whether a language passes by value, pointer or reference is a mind-set that many C, C++, Rust and other developers used to strongly/statically typed languages can have. However, JavaScript, Java, C# and more simply don’t work like that. This StackOverflow answer concisely sums up what JavaScript actually is: https://stackoverflow.com/a/6605700

As such, because B === A, mutating B with .push(9) also (possibly unexpectedly) changes A as well.

This is why both A and B have a 9 on the end of them. Similarly, this is why line 4 adds 1 to the first value of both A and B.

Wait, are you saying .sort() converts all the values to strings? Shouldn’t the first value be “11”, not 2?

No. Remember that after sorting and pushing, the values of A were [1, 18, 2, 3, 9]. We then called A[0] += 1 — add the number 1 to the first value of the array.

In JavaScript, adding a string and a number will convert both sides to strings before adding. As such: "1" + 1 === "11"

However, note though that .sort() only converts to strings for the purpose of sorting — the original value is then placed back in. As such, numbers remain numbers, functions remain functions and so on. This means it really is just 1 + 1 === 2.

We used const to declare A B and C, and then mutated these values. What’s happening there?

const in JavaScript (specifically ES6 / ES2015) refers to the declaration of a variable, and does not act the same way as it would in C++. It is closer to Java's final.

const person = { name: "Topher" };
person.name = "Ali";
person = { name: "Ian" }; // will throw "Uncaught TypeError: Assignment to constant variable."
const A = 5;
A = 7; // will throw "Uncaught TypeError: Assignment to constant variable."
A += 1; // will throw "Uncaught TypeError: Assignment to constant variable."

What is happening here?

As shown in the above StackOverflow answer, you may mutate or reassign a property on a const variable, but you may not reassign the variable itself.

For something more similar to C++’s understanding of const, please see Object.freeze(myObject);.

The Principle Of Least Astonishment

This post is not designed to point at JavaScript and laugh, or say it’s a bad language. It’s old and has evolved over time, while keeping backwards compatibility along the way — sadly this means that newer developers may run into problems there were created years ago.

Instead, I want to encourage you to consider how others will read and use your own code. This post highlights how a few decisions that are confusing can combine very rapidly to make almost impenetrable code, despite it appearing so simple.

Be sure to involve many other engineers, of various seniority levels, in the design of systems that you expect others to use. (Hint: no matter how obscure or small your piece of code, someone else will find it and attempt to use it.)

Please let me know on Twitter if you’ve come across any other astonishing code!