The rule of least power suggests that:
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:
- It can have references to variables and native javascript objects, e.g.
Set
,Map
,RegExp
and even functions. - It has a more complex syntax, e.g. keys without
"
, keys with[]
to refer to other variables etc.
In contrast, JSON is much less powerful:
- It only supports strings, numbers, JSON object, arrays, boolean and
null
. - You can only define an entry with
"property": ...
.
Although JSON is less powerful, it is much more straight-forward to parse and understand, both by humans and computers. This is one of the reasons why JSON has become the standard in data transfer nowadays.
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.
Abstract
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.
Expression Power and Readability
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 examples
// 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 }) => { ... }
"Depowering"
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.
Depowering by conventions
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) // ✅
Depowering by abstractions
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 library. This library hide the power of the almighty RegExp, exposing an API specific for path matching.
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
. Below is a super naive non-practical implementation:
// 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 I found on egghead. It goes through fundamental functional programming concepts in a fun and engaging way! Totally recommend!
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>) => { ... }
Pinch of salt
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!
Top comments (5)
Good insight! Could become a nice talk imho
Thank you! I have actually submitted a proposal to talk about this in multiple online conferences! Looking forward to talk about this to wider audiences!
Good luck!!
As always Jason, you share easy to consume ideas and information in a way this old timer understands.
🤘
Thank you
😭 Thanks man! And you are as always supportive! 💚
The realisation in this article means a lot to me. I hope the community would give me feedback on this. Whether they agree or not, I value every discussion as they can help us see a clearer picture.