The Empathetic Engineer: Developing for Humans

Read The Empathetic Engineer: Part 1 here. TLDR; empathy is an extremely important skill for engineers of all levels. You'll write better code for yourself and others if you keep the people reading your code in mind.


To stay empathetic when I write code, I have developed a set of principles that I try to conform to whenever possible. These principles are language-agnostic and can be applied to code at all levels. There's nothing revolutionary in here, but I find that they generally help keep myself and others on the right track to writing code that is first and foremost for humans.

1. Quirks are not cool

As programmers we sometimes take a perverse delight in doing things in a weird or tricky way just because we can. Using quirks of a language may make you feel smart, but they make understanding your code much more complicated for other people.

Example (in JS):

// Using !! quirk to cast to a boolean value:
const foo = !!bar;  
// Instead, be boring but explicit:
const foo = Boolean(bar);  

A common rebuttal to this point is: "but everyone knows what !! means". No, they don't. People new to programming or the even the language will not understand your code at first - you're creating an unnecessary barrier to learning.

2. Comprehension over Keystrokes

Keystrokes are cheap, comprehension is expensive. Your code will be read many more times than it's changed. Optimising for the reader is a great sign of an empathetic engineer, but because it's tricky many people often have a "this'll do" attitude towards naming. This is especially important when we can't communicate context directly to the reader.

The most common excuse for not writing good names is that they're "slower to type". It's 2017 - we have incredible editors which will let you tab-complete most tokens. There's really no excuse for shitty variable names.

Example (in Go):

fmt.Println("What the £%&$ does fmt mean?")  

The first thing you'll encounter in a Go 'hello world' is fmt.Println. If you're ever written go, you'll probably know this means format.PrintLine - but you didn't always know that, you had to learn it. Not particularly difficult, no, but now imagine that English is your second or third language and you teammate says:

"use the 'fumpt' package".

This is the de-facto pronunciation for 'fmt' in the Golang world and is a cause of totally unnecessary confusion for the sake of saving 3 characters. Just call it format.

3. Abracadont

Thankfully there are countless articles which describe how to "avoid magic" and embrace the "Principle of least astonishment" when writing code. These often relate to designing user interfaces and APIs, but it's just as important when writing any function. Avoid side-effects and try to communicate context through your code. I personally like to use language features and libraries to force me into following these principles. For example, in JavaScript I'll only ever use const instead of let or var and I'll use immutable data structures where possible.

A great example of a confusing side-effect is Go's pprof library. Just by including the package with import _ "net/http/pprof", the init() function runs and registers handlers on the global http server. A designed which was intended as 'simple to use' now has some interesting and unexpected consequences. Instead, why not require the user to explicitly make the call. It's an extra line of code, but it's a whole lot less magic.

4. Hutches not Holes

When we do have to write complex code, we expect that the consumer will have to take some time to grok what's really going on. Nobody understands code completely on the first pass through, so it's important to optimise for readability by thinking about not only the code, but the path through the code that the reader will take.

The simplest manifestation of this is project structure - we break our code up into independent, logically grouped modules so that it's easy for the reader to create a mental map of the code. This same organisation should apply at the function and line level too.

I like to think about this principle in terms of a 'cognitive stack'. For every function call that happens, I have to push the current state on to the stack, then jump to the new function and understand what's happening. Once I've figured out what that function does, I pop it from my cognitive stack and continue understanding the execution path of the code. When there are too many function calls in the cognitive stack (i.e. we're down a rabbit hole), it becomes extremely difficult to reason about a program.

An example of this might be:

func main(input string) {  
  ParseJSON(input)
}

func ParseJSON(input string) {  
  if message.IsJSON(input) {
    parsed := json.Parse(input)
    PrintFoo(parsed)
  }
  fmt.Println("message is not json")
}

func PrintFoo(message Message) {  
  fmt.Println(message.Foo)
}

In this (contrived) example, the reader needs to keep main, ParseJSON, and PrintFoo on their cognitive stack to understand what the code is going to do. Instead, we can rewrite our function to minimise the overhead:

func main(input string {  
  if !message.IsJSON(input) {
    fmt.Println("message is not json")
    return
  }
  parsed := json.Parse(input)
  result := ExtractField(parsed, "foo")
  fmt.Println(result)
}

Here, we hoist the check to see if the message is JSON out of the ParseJSON function removing the need for it entirely. This allows the reader to quickly understand the flow. This becomes even more pronounced when each function is defined off screen or in a different file.

5. Declarative Over Imperative

Simply put, imperative language tells the machine exactly how to do something, whereas declarative language only describes what you want doing.

This can be demonstrated by taking pretty much anything you'd want to commonly do in a functional paradigm (functional is a superset of declarative) and trying to implement it in Go. Alright, that was a bit flippant, but adherence to this principle is one of the things that forces me into a love/hate relationship with Golang.

If we want to find all even numbers in a slice, the imperative way would be to create a for loop, iterate through the list and append to a new results slice:

numbers := []int{1,2,3,4,5}  
results := []make([]int, 0)  
for i:=0; i < len(numbers); i++ {  
  if numbers[i] % 2 == 0 {
    results = append(results, numbers[i])
  }
}

Notice that at no point in this code does it reference the fact that what we actually want to do is to find only even numbers. The reader has to induce your intentions from the implementation details. The declarative style makes that much clearer:

var isEven = func(n int) { return n % 2 == 0 }  
numbers := []int{1,2,3,4,5}  
evens := Filter(numbers, isEven)  

Here, the reader can deduce that you're filtering for even numbers by reading the code and can seek confirmation by digging in to the Filter function if needed. In broader terms, this principle could be considered Deduction over Induction, but I think that's less concrete.

6. Consistency is King

And finally, a principle which can be considered a minor caveat; consistency is an extremely important part of readability. Just like context switching between tasks and languages has some cognitive overhead, switching between code-bases written in radically different styles will also cost time and effort. Sticking with the existing style is often preferable to introducing a new one, even if the new one is considered more readable.

Migrating styles requires a strong commitment and should be a conscious effort from the whole team. Introducing a second style without a clear path to clearing up the original one is only going to increase the cognitive overhead of people learning that code base.


There are a few more things I usually talk about, but in the interest of brevity, I'll leave it at these. In the next part, learn about some ways to improve your empathy so that these principles become more natural to implement in practise.