Enhancing Code Readability by Hiding Implementation Details.
The key to write maintainable code is all about abstraction
The Key to Code Readability
One key to making code read smoothly is correctly handling different levels of abstraction. Ideally, abstractions within the same function should remain at the same level. You can allow readers to move between different levels, but you shouldn't force them to do so.
The concept of abstraction levels is similar to the language we use when communicating with others. For example, when writing an article or giving a presentation, following a structure of conclusion-details-conclusion will help the audience naturally understand the main idea and follow the speaker's logic.
The same principle applies to writing code. In a previous blog post, I used an example of filtering a list of users. The code isn't very long and mainly transforms the input users array. It filters elements that meet certain criteria (experience greater than 3, role is Engineer) and replaces empty values with "N/A".
const transformUsers = (users: User[]) => {
const results = [];
for (let i = 0; i < users.length; i++) {
const user = users[i];
if (user.experience > 3 && user.role === "Engineer") {
results.push({
...user,
location: user.location === null ? "N/A" : user.location,
});
}
}
return results;
};
If you read the code carefully, you'll notice that it contains multiple levels of abstraction. In other words, it uses different languages inside the same function body. These intertwined languages make the code appear messy to read. While this mixing may not be a serious problem in this small example, it can become quite challenging to understand the meaning of longer functions with more details to handle.
If we highlight different abstractions (languages) with different colours, the code would look pretty colourful. I used orange to represent array operations, green for list iteration operations, and blue for business logic. When reading, the developer needs to switch from one abstraction (language) to another, and undoubtedly, such switching reduces reading efficiency.
Let’s have a close look at these different abstractions.
Iterating through a List
The purpose of a for loop is to iterate through a list and access each element in turn. The language used here to iterate through a list includes the starting and ending index of the iteration, how the index is incremented, and how the array elements are accessed.
for (let i = 0; i < users.length; i++) {
const user = users[i];
}
However, this pattern is so ingrained that when reading it, most programmers unconsciously skip over the declaration of the index, the self-incrementing operation, and so on. This snippet is somewhat considered transparent, like visual noise such as comments.
List operation
In addition, we have some code that manipulates an array. We declare an array (in TypeScript, we need to consider the type), then use the push
action to modify the array, and finally, return the result.
const results = [];
results.push({...});
return results;
Here, the language context shifts from for-loop to list operations, defining lists, using APIs such as push or results[i] to modify lists, and so on.
Business Logic
Next, we need to handle a simple logic: check if a user's experience field is greater than 3 and their role field value is Engineer. The language used here is the so-called domain knowledge. Typically, this knowledge is communicated to programmers by business analysts and then implemented in the code.
user.experience > 3 && user.role === "Engineer"
Developers spend most of their time dealing with this kind of logic, such as only charging the price of four items if there are more than five items of a certain product in an order. Or, if the total amount exceeds $30, then waive the shipping fee.
The logic of handling null values is also part of domain knowledge, but here it focuses more on how to present it to the end-user (showing “N/A” as a fallback).
{
...user,
location: user.location === null ? "N/A" : user.location,
}
Based on experience, this part of the logic is often prone to change. For example, in the previous example, the role field may be Engineer
or engineer
and we need to ignore case sensitivity. Or, experience
may need to be greater than 5, or some other conditions need to be added, and so on. Here, our language has once again shifted from list operation to domain knowledge.
Separating Concerns through Abstraction
By refactoring the code and extracting small functions with business meaning, and using collection APIs to hide the iteration details, the code becomes easier to read. The fundamental principle here is that we hide many details in the function transformUsers
and express specific operations through the names of some functions. As a result, the readability of the code naturally improves.
const fillBlankLocation = (user: User) => ({
...user,
location: user.location === null ? "N/A" : user.location,
})
const isSeniorEngineer = (user: User) =>
user.experience > 3 && user.role === "Engineer";
const transformUsers = (users: User[]) =>
users.filter(isSeniorEngineer).map(fillBlankLocation);
Similarly, using more meaningful function names makes the code more readable and easier to understand. The use of different abstractions also reduces the reader's cognitive load. With more meaningful function names, the code makes more sense in the domain.
The simplified code can be read as filtering the user list for those who isSeniorEngineer
and then mapping the results with fillBlankLocation
. The details, like, what conditions define a senior engineer and how the blank is filled, are hidden behind these function names.
Summary
Refactoring is a technique, sometimes even considered an art, that aims to put code in the right place. By consciously separating the concrete implementation from the name, we can often obtain simple, composable, and business-meaningful small functions. Using these small functions at a higher level of abstraction can help improve code readability. Ideally, each function body only involves the same abstraction level, while the specific implementation details are pushed down to the next level (or even the next level down).