Coding is both an art and a science. A well-written software doesn't only solve the problem at hand, but does so in a way that is easy to understand, efficient to run, and effortless to maintain. We are (still) writing code for humans, not computers. So, it's important to write code that is easy to read, understand, and modify. Also, if you think that you are building a house, would you want to build it on a weak foundation? No, right? The same goes for software. If you want to build a robust software, you need to have a strong foundation.
Wresting harmony out of the inherent complexity of software coding is challenging but achievable. In this blog post, I go into some essential coding practices that can help streamline your code, improve its readability, and make it easier to grow.
Let's get started with the WORST practices first!
Nested If Statements: Decomplexifying the Logic
Nested if statements, sometimes being the simplest approach to solve a problem, can cause a great deal of complexity when it comes to code readability and maintenance. Let's take a look at a simple yet messy example in TypeScript, where we decide an action based on user's role and user's action:
function performAction(userRole: string, userAction: string) {
if (userRole === 'admin') {
if (userAction === 'delete') {
// Do something.
} else if (userAction === 'edit') {
// Do something else.
}
} else if (userRole == 'user') {
// Some other code.
}
}
This could be more readable by using a switch statement and breaking the function into smaller functions:
function performAction(userRole: string, userAction: string) {
switch (userRole) {
case 'admin':
performAdminAction(userAction)
break
case 'user':
// Some other code.
break
}
}
function performAdminAction(userAction: string) {
switch (userAction) {
case 'delete':
// Do something.
break
case 'edit':
// Do something else.
break
}
}
Avoiding Else: Embrace Returning Early
As developers, we aim to reduce conditional complexity to make our code easier to read and understand. One way of achieving this is by avoiding 'else' statements and returning early from a function. If a condition is present in the function that makes the subsequent code irrelevant, instead of wrapping the code inside an 'else' statement, we can return from the function. This helps in reducing the indentation level and makes the code cleaner:
// Without return early technique
function isAdmin(userRole: string): boolean {
if (userRole === 'admin') {
return true
} else {
return false
}
}
// With return early technique
function isAdmin(userRole: string): boolean {
if (userRole === 'admin') {
return true
}
return false
}
External Wrapping and Package Organization
Dependent packages are like double-edged swords. While they are extremely useful in solving complex problems on one hand, they can leave us stranded if they fail. In those situations, isolating the dependencies by wrapping them becomes our life savior as that would only allow small parts of our code to fail without affecting the complete ecosystem, and also help us simplify our tests by mocking these wrappers.
By keeping these external packages in their own file on ~/utils
, we can
improve code organization which can immensely help in simple navigation and
comprehension.
// ~/utils/axiosInstance.ts
import axios from 'axios'
const axiosInstance = axios.create({
baseURL: 'https://api.example.com',
})
export default axiosInstance
The above command can be used across the app, simplifying how we use Axios.
The Async/Await revolution
The advent of async/await has revolutionized how we handle asynchronous operations, helping us write asynchronous code that looks and behaves more like synchronous code, saving us from the problem of callback hell.
// Callback way
function getUser(callback) {
database.getUser(function (user) {
if (user) {
callback(null, user)
} else {
callback(new Error('User not found'))
}
})
}
// Async/Await way
async function getUser(username: string) {
try {
const user = await database.getUser(username)
return user
} catch (err) {
throw new Error('User not found')
}
}
In the above example, the async/await way is much cleaner, easier to read, and comprehend.
No Temporary Stuff in Functions: The Pure Way
A pure function is a function where the return value is determined only by its input values, without any observable side effects. This means no mutation of local static variables, non-local variables, mutable reference arguments or I/O streams.
// Not pure function
let tax = 5
function calculateTax(price: number) {
return price + price * (tax / 100)
}
// Pure function
function calculateTax(price: number, tax: number) {
return price + price * (tax / 100)
}
In the above example, the second function is a pure function as it will always return the same output given the same input and not rely on any hidden state.
Test-Driven Development: Co-locating Tests with Actual Code
Keeping test files alongside the actual code files promotes the practice of Test-Driven Development (TDD) making it easier to find and relate tests with its corresponding code file.
Here is an example:
/lib
math.ts
math.test.ts
In this setup, you can easily see what code is covered by tests and what code is not.
Using these practices, we can keep our code clean, maintainable, and efficient. As the saying goes, "Easier to read, easier to change so it's easier to fix"!
Remember, code is more often read than written. So write it, keeping the reader in mind.
As Margaret Hamilton, the software engineer for the Apollo Mission, said, "Our ULTIMATE objective was to improve and ensure the reliability of the software... If the software did not work or if it contained errors, then the AGC would fail and therefore the mission would also be in serious jeopardy."
In light of this, we can understand the importance of good code quality: it not only affects the deliverables but also the overall mission and objective of the project.