The rule of least power
the less powerful the [computer] language, the more you can do with the data stored in that language.
An example of this would be JSON vs Javascript object literal
Javascript object literal is clearly more powerful:
Set
, Map
, RegExp
and even functions."
, keys with []
to refer to other variables etc.In contrast, JSON
null
."property": ...
.Although JSON
I learnt about this rule a few years back; but have only recently realised it can also improve the quality of our code.
I would extend the rule of least power, so that it is not only applicable to choices amongst computer languages / systems, but also to choices amongst every line of code we write.
This article uses Javascript in the examples but the principle is applicable to other languages.
When writing computer programs, one is often faced with a choice between multiple ways to express a condition, or to perform an operation, or to solve some problem. The "Rule of Least Power" (extended) suggests choosing the least powerful way suitable for a given purpose.
Readability of a piece of code has huge impact on maintainability, extensibility, optimisability etc. Readable code is much easier to be analysed, refactored and built on top of. This section explores the connection between the choice of expressions and the readability of a piece of code.
Principle: Powerful expression inhibits readability.
The power of an expression can also be thought of as "how much more it can do beyond achieving a specific purpose".
Consider the following example:
// More powerful: RegExp.prototype.test
/hi/.test(str)
// Less powerful: String.prototype.includes
str.includes('hi')
The first expression /hi/.test(str)
is more powerful because you could do so much more with regex. str.includes('hi')
is pretty much all String.prototype.includes
can do.
The reason why str.includes('hi')
is more readable is that it requires no extra thinking to understand it. You can be 100% sure that str.includes(...)
will only check if ...
is a substring of str
. In the contrary, /.../.test(str)
would require reading into ...
in order to figure out what it actually does.
Consider another example:
// More powerful: Array.prototype.reduce
['a', 'b', 'c'].reduce((acc, key) => ({
...acc,
[key]: null
}), {})
// Less powerful: Object.fromEntries + Array.prototype.map
Object.fromEntries(['a', 'b', 'c'].map(key => [key, null]))
The same arguments about power and readability apply similarly here. ['a', 'b', 'c'].reduce(...)
can reduce to literally anything, whereas Object.fromEntries(...)
will definitely return an object. Hence, Array.prototype.reduce
is more powerful; and Object.fromEntries(...)
is more readable.
// More powerful: RegExp.prototype.test
/^hi$/.test(str)
// Less powerful: ===
str === 'hi'
// More powerful: RegExp.prototype.test
/^hi/.test(str)
// Less powerful: String.prototype.startsWith
str.startsWith('hi')
// More powerful: RegExp.prototype.test
/hi$/.test(str)
// Less powerful: String.prototype.endsWith
str.endsWith('hi')
/// More powerful: Array.protype.reduce
xs.reduce((x, y) => x > y ? x : y, -Infinity)
// Less powerful: Math.max
Math.max(...xs)
// More powerful: Array.prototype.reduce
parts.reduce((acc, part) => ({ ...acc, ...part }), {})
// Less powerful: Object.assign
Object.assign({}, ...parts)
// More powerful: Object.assign - can mutate first object
Object.assign({}, a, b)
// Less powerful: Object spread
{ ...a, ...b }
// More powerful: function - have its own `this`
function f() { ... }
// Less powerful: arrow function
const f = () => {...}
// More powerful: without destructure - who knows what the function will
// do with the universe
const f = (universe) => { ... }
// Less powerful - f only needs earth
const f = ({ earth }) => { ... }
At this point, we have established and demonstrated how powerful expression can come with some readability tradeoffs. This section explores the possibility to reduce power of an expression in order to increase readability.
The holy trinity of array methods .map
, .filter
and .reduce
were borrowed from functional programming languages where side-effects are not possible.
The freedom, that Javascript and many other languages provide, has made the holy trinity more powerful than they should be. Since there is no limitation about side-effects, they are as powerful as a for
or while
loop when they shouldn't be.
const xs = []
const ys = []
for (let i = 0; i < 1000; i++) {
xs.push(i)
ys.unshift(i)
}
// we can also use map / filter / reduce
const xs = []
const ys = []
Array.from({ length: 1000 }).filter((_, i) => {
xs.push(i)
ys.unshift(i)
})
The above example demonstrates how the holy trinity are able to do what a for
loop is capable of. This extra power, as argued in previous section, incurs readability tradeoffs. The reader would now need to worry about side-effects.
We can dumb down / "depower" .map
, .filter
and .reduce
and make them more readable by reinforcing a "no side-effect" convention.
[1, 2, 3].map(f) // returns [f(1), f(2), f(3)] AND DO NOTHING ELSE
xs.filter(f) // returns a subset of xs where all the elements satisfy f AND DO NOTHING ELSE
xs.reduce(f) // reduce to something AND DO NOTHING ELSE
.reduce
is the most powerful comparing the other two. In fact, you can define the other two with .reduce
:
const map = (xs, fn) => xs.reduce((acc, x) => [...acc, fn(x)], [])
const filter = (xs, fn) => xs.reduce((acc, x) => fn(x) ? [...acc, x] : acc, [])
Due to this power, I personally like another convention to further depower .reduce
. The convention is to always reduce to the type of the elements of the array.
For Example, an array of numbers should try to always reduce to a number.
xs.reduce((x, y) => x + y, 0) // ✅
people.reduce((p1, p2) => p1.age + p2.age, 0) // ❌
people
.map(({ age }) => age)
.reduce((x, y) => x + y, 0) // ✅
Abstractions are a good way to depower expressions. An abstraction could be a function, data structure or even types. The idea is to hide some power under the abstraction, exposing only what is needed for the specific purpose.
A great example would be the popular Path-to-RegExp
For example
pathToRegExp('/hello/:name')
// will be compiled to
/^\/hello\/(?:([^\/]+?))\/?$/i
Here is a more advanced example.
const y = !!x && f(x)
return !!y && g(y)
!!x && f(x)
is common pattern to make sure x
is truthy before calling f(x)
. The &&
operator can definitely do more than just that, as there is no restriction about what you can put on either side of &&
.
A way to abstract this is the famous data structure: Maybe
aka Option
// Maybe a = Just a | Nothing
const Maybe = x => !!x ? Just(x) : Nothing()
const Just = x => ({
map: f => Maybe(f(x))
})
const Nothing = () => ({
map: f => Nothing()
})
Yes! Maybe is a functor
With this abstraction, we can write the following instead:
return Maybe(x).map(f).map(g)
In this example, Maybe
hides away the &&
it is doing internally, giving confidence to readers that f
and g
can be safely executed, or ignored depending on x
and f(x)
.
If you are interested in learning more about data structures like this, take this course
The last example is depowering via types. I will use typescript to demonstrate.
type Person = {
name: string
age: number
height: number
weight: number
}
// More powerful - is f going to do anything with the person?
const f = (person: Person) => { ... }
// Less powerful - f only needs the name. But will it mutate it?
const f = (person: Pick<Person, 'name'>) => { ... }
// Even less powerful - f only reads the name from the person
const f = (person: Readonly<NamedThing>) => { ... }
Please take the advice in this article with a pinch of salt.
This article highlights my formalisation about the relationship between the power of an expression and readability. And ways that we can depower expressions to increase readability.
There are still many factors contributes towards the readability of a piece of code besides the power of expressions. Do not blindly choose the less powerful expression. Do not "depower" every line of code into a function call. Do not put every variables into Maybe
.
I am still in constant discovery and theorization on the topic of "good code". My mind might change over time. But ever since I introduced this idea to my team, we have not found a single instance where this rule fails. We even start using #ROLP
(Rule Of Least Power) to reason about why one code is better than the other. So my faith is strong here, and is growing every day.
I hope the rule of least power (extended) can inspire you to produce better code in the future! Please experiment with it and let me know what you think!