Writing cleaner code with the rule of least power #ROLP

YCM Jason - Apr 8 '20 - - Dev Community

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')
Enter fullscreen mode Exit fullscreen mode

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]))
Enter fullscreen mode Exit fullscreen mode

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 }) => { ... }
Enter fullscreen mode Exit fullscreen mode

"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)
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

.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, [])
Enter fullscreen mode Exit fullscreen mode

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) // ✅
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Here is a more advanced example.

const y = !!x && f(x)
return !!y && g(y)
Enter fullscreen mode Exit fullscreen mode

!!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()
})
Enter fullscreen mode Exit fullscreen mode

Yes! Maybe is a functor

With this abstraction, we can write the following instead:

return Maybe(x).map(f).map(g)
Enter fullscreen mode Exit fullscreen mode

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>) => { ... }
Enter fullscreen mode Exit fullscreen mode

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!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .