4 Simple Tips to Write Readable Code

Writing clear and readable code is key to developing software that we, people, can manage.

Andree Surya
DKatalis
8 min readJan 19, 2023

--

As software engineers, we typically have an end goal in mind when writing code. We naturally want to get things done, and we want things to be well-done. Even so, it’s too bad that only a few will pay due attention to code readability during this process. Even when the code works without a hitch, you might struggle to make sense of it after a couple of days, let alone months or years. If you’re working in a team, this might slow down your mates as well.

Although programming is often stereotyped as a solitary job, seasoned programmers know it is not. Your code will be read and used by others for countless reasons. People will need to adapt and repurpose your code to enable new features. They need to skim through your code to investigate a pesky bug. Not just a mere machine instruction, your code is a social conduit through which you empower others, so you do need to make sure it’s effective in this regard.

Without any care to its readability aspect, the act of writing code could introduce needless complexity, hence confusion, misunderstanding, and misuse. These are the last things that we want in any collective software development effort, moreover at scale. Writing clear and readable code is key to developing software that we — people, can manage. In this article, we’ll share some simple tips that can help improve the readability of your code.

1. Strive to keep your code interface simple

Function signature, OOP class, or HTTP API. These are all essentially interfaces and abstractions that we can leverage to eventually build a complete software. When designing these interfaces, most people settle with something that simply works, with minimal regard to ease of use. This is too bad because inadequately-designed interfaces can risk misunderstanding, misuse, and clutter your client code.

As a case in point, what do you think when you see an HTTP client library with this interface?

// Create new HTTP client for api.provider.com
const httpClient = new HttpClient(
'https://api.provider.com',
{
'x-api-key': process.env.API_KEY
},
10000,
true,
'json',
null,

)

This contrived example presents a seemingly capable HTTP client. It does seem to allow you to customize the default base URL of the target API, default headers, timeout value, and perhaps many others. But, how are we supposed to navigate this interface where all these capabilities are exposed at once in a single constructor call? Presenting all these at once only serves to burden your users with needless cognitive load, because we’re forced to assess and decide for each use case.

When you’re designing any function signature, any OOP class, or any HTTP API, you do need to consider your primary target users. Do ponder, what would be the likely use case for this interface? Instead of exposing all capabilities upfront, perhaps we can do a favor for our users by deciding on a reasonably safe default. This would allow us to present a simple, basic interface that is uncluttered and intuitive for its primary use case, yet still provides the option to make more advanced customization.

Let’s see how the popular Redis client library ioredis handles the situation.

// Basic usage for new users
// By default, connect to localhost:6379 with no authentication
const redis = new Redis();

The Redis constructor interface is designed in such a way as to accommodate users of various skill levels and cater to various use cases. New users can jump in immediately and try things out with a simple new Redis() command. They don’t have to scour the documentation to understand what maxScriptsCachingTime is and what would be an appropriate value for it. Things would just work with sensible defaults.

More advanced options, intended for users who already know their way around and do need more control, can be achieved by providing extra named parameters to the constructor.

// Advance usage for users who already know their way around
const redis = new Redis({
host: "my-org.aivencloud.com",
port: 6379,
username: "org-name",
password: "my-top-secret",
...
});

Much better, don’t you think? As code maintainers, we don’t want to clutter our code with things that are irrelevant to our use case, and it’s good that the library presented here is supportive in that regard.

2. Think twice, or thrice, before you name

What’s in a name? That which we call a rose by any other name would smell as sweet.

And so said Juliet, implying that the naming of things is irrelevant. Names are simply agreements between us to tell things apart, and very often irrelevant to the quality of the subject. As beings who communicate, though, we know that names are valued exactly for that reason: as an agreement.

I can tell you stories, all because we can agree on how to tell things apart. As coders, if we can read and understand each others’ code, it’s all because we have a collective understanding of how to name things, and we decided to abide by them. If I decided to veer away from this convention, that is to say, if I name things badly, it would obscure the nature of things. Worse, it could mislead you. And this could possibly lead to all sorts of confusion and bugs.

So when you write code, please don’t take the responsibility of naming it lightly. Choose names that can precisely communicate the nature of data or functions. Think twice, or thrice, before you name, because good names tell stories, but bad names only obscure.

As an example, let’s examine this piece of code from a popular date/time library luxon.

export function daysInMonth(year, month) {
const modMonth = floorMod(month - 1, 12) + 1,
modYear = year + (month - modMonth) / 12;

if (modMonth === 2) {
return isLeapYear(modYear) ? 29 : 28;
} else {
return [31, null, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][modMonth - 1];
}
}

Can you guess what this piece of code is doing? From how the function and parameters are named, you could probably guess that this function calculates the number of days in a given month and year. These are some good, precise names. But here’s a small challenge: can you guess what’s the variable modMonth and modYear above?

I believe this might take you more time to guess. What is this mod anyway? Does that refer to “modified”? Mathematical “modulo” operator? Upon deeper inspection, it might come to light that the function has special handling for overflowed month numbering and transform this into a normal range of numbers 1 to 12. For example, the year 2022 and month 13 would be considered as the year 2023 month 1.

I think it would be fair to consider the naming of these variables as inadequate and obscure — the name doesn’t shed any light on its nature. We spent some wasteful deciphering effort until we can finally uncover the story behind it. Maybe one or two variables like these won’t be such a big deal, but imagine a millions-LOC codebase heavily sprinkled with this pattern all over. I suppose it must take us forever to get anything done.

As a constructive suggestion, I would name these two variables as normalizedMonth and normalizedYear to emphasize the normalization applied to the month and year. And perhaps further explain the nature of the normalization process through some short code comments.

3. Do write code comments, judiciously

Uncle Bob Martin once tweeted:

“A comment is a failure to express yourself in code. If you fail, then write a comment; but try not to fail.”

I wholeheartedly agree. And with the expressive power of modern programming language, it’s easier than ever to write code like prose.

Sadly, we do have to admit that our current tech is not perfect. Things like concepts, hidden gotchas, and contextual reasonings still exist. For the time being, these sorts of stuff could still be better expressed through plain-old English. We still have to resort to code comments, out of necessity, for the better good.

Just like bad naming, it’s easy to write comments that serve nothing, adding obscurity, or even misleading. As a rule of thumb, we would suggest that code comments should complement, rather than duplicate the information already presented in your code. If the code alone can tell your story, then we should just let it be.

As a reference, let’s analyze this one code interface from the date/time library luxon. Original code comments are omitted from this abstraction, just to make a point.

// Original comments are omitted from this interface
export default class Interval {

static fromDateTimes(start, end) {

}
}

Considering the context that this is a date/time library, you might be able to guess that Interval represents an interval of time, and fromDateTimes might be its corresponding factory function. I think this is a sufficiently precise name, when we do factor-in the surrounding context. However, this abstraction alone still cannot tell much. Is start and end a closed range, or an open range? What kind of data value is accepted for both? Can we perhaps use Unix timestamps?

Now let’s try again, with the original code comments incorporated.

/**
* An Interval object represents a half-open interval of time, where each endpoint is a {@link DateTime}. Conceptually, it's a container for those two endpoints, accompanied by methods for creating, parsing, interrogating, comparing, transforming, and formatting them.
*/
export default class Interval {

/**
* Create an Interval from a start DateTime and an end DateTime. Inclusive of the start but not the end.
* @param {DateTime|Date|Object} start
* @param {DateTime|Date|Object} end
* @return {Interval}
*/
static fromDateTimes(start, end) {

}
}

Now with the code comments, we’re clear about the half-open nature of the interval, the operations that could be performed against the interval, and acceptable parameter data types for its factory function. If you’re using an IDE, thanks to the data type annotation, the tool can help you perform type-checking even though you’re working on a dynamically-typed language.

In this particular example, code comments are informative and functional. It complements, rather than duplicates information. I know better how to use this abstraction, thanks to the extra clue. If you think your comments can help the reader as this one does, please go ahead, full-throttle.

4. Get your code reviewed

We can put our best effort into making readable codes, but as the original code writer, we have to accept that we are biased. As the saying goes, “Readability is in the eye of the beholder”, to assert readability, you need to get some peer reviews. Code review sessions and Git pull requests are the best testing grounds for you.

See if your teammates can understand your code. Some people think that they first need to learn about the surrounding business context before they can understand the code. I think it should be the other way around; a good code is one that can help people to learn more about the business. Think of it as a litmus test for readability.

Of course, this is even more important if you work in a collective codebase. Announce your pull request. Arrange a Slack Huddle with your teammates, and share live feedback with each other. Hopefully, you can help establish a healthy code review culture in your work environment. You’ll be rewarded with shared codebases that are pleasant to work with.

Conclusion

I once encountered a quote from Harold Abelson:

“Programs must be written for people to read, and only incidentally for machines to execute.”

This is a funny statement, yet, strikingly relatable. With readable code, at least we have a guiding light in navigating this enormously complex endeavor that is software development. I just hope that these simple tips can help.

To put great care into code readability is a good habit that we at DKatalis are trying to foster in our engineers. If you enjoy writing clean and readable code, and interested in developing financial digital solutions, you might be the Katalis we’re looking for. Just want to let you know that we’re hiring!

--

--