6 Rules to Write Better Programs at Scale
Almaju
Posted on August 14, 2024
1. Choose One Pair of Scissors
Imagine you’re cutting a piece of paper. Some might reach for scissors, others a knife, or maybe even fold and tear it by hand. Experimenting with different methods is fine, but if our goal is to cut paper all day—and do it efficiently—we need to agree on one method. Let's pick scissors: they’re consistent and safer, though that doesn’t mean they’re inherently better than other methods. It just means we’ve agreed, as a team, that this is our go-to approach.
The same applies to our work. In some programming languages, like Gleam, there’s one clear way to do things, making it easier to stay focused. But in TypeScript, the options are endless—no set file naming conventions, multiple ways to export, string concatenation, nullable values and debates over mutability, classes, and variables. Without a consistent approach, it can quickly become a free-for-all, with everyone doing things their way.
That’s why we need a single agreed-upon method, documented or verbally agreed upon. This doesn’t mean we can’t ever challenge the status quo—if we find a better way, we should discuss it as a team. But if we do decide to change, we need to commit fully, refactoring our work to avoid a confusing mix of old and new methods. What is our file naming convention? Do we default or named exports? Do we use null
or undefined
? All of these questions need an answer to maintain consistency.
Bad Code:
const MyFunction = () => "";
const myFunction = () => "";
const my_function = () => "";
// Make up your mind!
Key Takeaways:
- Consistency matters: Agree on one way to do things to avoid chaos.
- Document the standard: Whether written or verbal, make sure everyone’s on the same page.
- Stay flexible, but committed: Challenge the standard if needed, but fully commit to changes.
2. A Notebook Page Needs a Title
Think of your code like a notebook. When I jot down ideas, I always put a big, clear title at the top of each page, so I can easily find what I'm looking for later. I also start a new page for each new idea, and if I need to reference another page, I write it in all caps to make it obvious. Now, imagine if I didn’t do this—if I mixed all my ideas together on the same pages, drawing arrows between them. It would be a mess, like trying to navigate a chaotic maze!
This is exactly the kind of organization we need in our code. Too often, I see files that are like those messy notebook pages—packed with multiple ideas, repeating concepts, and tangled references that create spaghetti code.
Now, you might think this is just common sense. But let me ask: do you have a helpers
folder filled with random functions, like capitalize
, just because you didn’t know where else to put them? Maybe you even have the same function duplicated because someone didn’t know it was already there.
Instead of stuffing everything into these catch-all files, think about the concepts behind your code. For example, instead of throwing a capitalize
function into a generic text-utils.ts
file, consider what you’re really working with—a word. Create a word.ts
file that focuses solely on the concept of a word, and put all related functions there. This makes your code more meaningful and organized, eliminating the need for vague “helpers” or “utils” folders.
Good Code:
// word.ts
export type Word = string;
export const capitalize = (self: Word) =>
string.charAt(0).toUpperCase() + string.slice(1);
Key Takeaways:
- One file, one concept: Keep each file focused on a single idea.
- Mindful references: Only mention other concepts, don’t define or manipulate them in the same file.
- Clear structure: Start your file with a type definition to set the subject, followed by functions that interact with that concept.
3. Stop Using a Wrench as a Hammer
Imagine you’re building a house. Now, you could use basic tools like a hammer and nails for everything—sure, they’ll get the job done, but wouldn’t it be better if you had a saw for cutting wood, a level for checking alignment, and a wrench for tightening bolts? Each tool is designed for a specific task, making the work not only easier but also more precise and professional. It’s the same in programming.
Too often, we rely on basic data structures like strings, numbers, objects, and promises. They’re flexible and can represent many things, but leaning on them too much is like building a house with just a hammer and nails. We have the opportunity to represent our ideas more clearly and efficiently by using more specialized structures—without the extra burden of deciphering complex functions.
Think about HTML. Technically, you could build an entire webpage using just <div>
, <a>
, and <input>
. But wouldn’t it be nicer to use elements like lists, headings, and navbars? Clean HTML code allows you to understand the structure and purpose of the page at a glance, without needing to see the final design or inspect the CSS. The same principle applies to programming:
- Want to represent errors as values? Use a
Result<T, E>
structure. - Want to ensure a string is never empty? Try newtype patterns.
- Managing unique documents? Use a
Set
. - Need to identify documents by unique IDs? Use a
HashMap
.
If there isn’t a native data structure that fits your idea, create one that does. Just like using the right tools in construction, using the right data structures in programming makes your work cleaner, clearer, and more effective.
Bad Code:
const getWeatherForecast = (): Promise<number[]> => { ... }
Good Code:
type Result<T, E> = Ok<T> | Err<E>;
type Ok<T> = { _tag: "Ok", value: T };
type Err<E> = { _tag: "Err", error: E };
const getWeatherForecast = (): Promise<Result<WeatherForecast, HttpError>> => { ... }
Key Takeaways:
- Specialized tools: Don’t rely solely on basic data structures; use ones that match your specific needs.
- Clarity in code: Well-chosen data structures make your code easier to understand and maintain.
- Custom solutions: If a native structure doesn’t fit, create one that does.
4. Don’t Bake a Cake with a Shovel
Imagine you’re trying to bake a cake, but instead of using the recipe’s recommended tools—a whisk, measuring cups, and a mixing bowl—you decide to use a shovel, a bucket, and a pair of chopsticks. Sure, you might get something resembling a cake in the end, but it’s going to be a messy, frustrating process, and the results won’t be nearly as good. The same idea applies to programming languages.
In TypeScript, I love how it handles type inference, dynamic unions, closures, and async functions. It’s like using the right kitchen tools for the right tasks. But try to force those same tasks into Rust, and you might end up with a complex and convoluted mess like Arc<dyn Fn(R) -> Pin<Box<dyn Future<Output = Result<T, E>> + Send>> + Send + Sync>
. It’s not that Rust is bad; it’s just better suited for other tasks, like working with traits and structs.
In TypeScript, it’s important to play to the language’s strengths instead of trying to force it into something it’s not. For instance, mutability can be a nightmare to manage, leading to all sorts of bugs. Classes and enums can also be problematic, as TypeScript doesn’t always distinguish between a class and an object with the same shape, leading to frustrating issues.
So, instead of bending TypeScript to act like a different language, embrace its functional nature. Skip the classes and use types and functions. It might be a bit more verbose, but it’s cleaner and less buggy. Don’t try to shoehorn in traits with inheritance, avoid mutation-heavy methods like delete
or Array.sort
, and steer clear of prototypes.
Just like using the right tools in the kitchen makes baking easier and more enjoyable, using the right features in a programming language makes coding smoother and more effective.
Bad Code:
class Foo extends ToString { // Inheritance is bad
constructor(private _data: string) {}
get data() { // Getters might mess with Typescript
return this._data;
}
}
// Playing with prototypes is hacky
Foo.prototype.setData = function(str: string) {
// `this` and mutations are a source of bugs
this._data = str;
}
Good Code:
type Foo = {
data: string;
}
const fromString = (str: string): Foo => ({
data: str
});
const update = (str: string) => (self: Foo): Foo => ({
...self,
data: str
});
Key Takeaways:
- Embrace the language’s strengths: Don’t force it to be something it’s not.
- Avoid common pitfalls: Be wary of mutability, classes, and methods that can lead to bugs.
- Stick to functional programming: Use types and functions to keep your code clean and maintainable.
5. Don’t Put Ketchup on Top of the Sandwich,
Think about making a sandwich. You start with the basics: bread, meat, cheese, and some veggies. Each ingredient is its own thing, but when you stack them together, you get something more—an actual sandwich! The key here is that each layer builds on the previous one, creating a final product that’s greater than the sum of its parts. In coding, we should aim for the same approach: letting concepts naturally build on one another to create clear, testable, and scalable programs.
Let’s say you want to write a simple program that sends a friendly “Hello!” email every day. You’ll need to handle a few concepts: the message, the email address, and the email client that sends the email. By breaking these down into their own modules and building them up step by step, we can create a clean and organized program.
Here’s how you could do it:
// message.ts
type Message = string;
const create = (): Message => "Hello!";
// email.ts
type Email = string;
const create = (): Email => "friend@example.com";
// email-client.ts
type EmailClient = {
send: (email: Email, message: Message) => Promise<void>
}
// daily-email.ts
import * as Message from './message';
import * as Email from './email';
import { EmailClient } from './email-client';
type DailyEmail = {
email: Email;
message: Message;
}
const create = (): DailyEmail => ({
email: Email.create(),
message: Message.create(),
})
const send = (self = create()) => async (emailClient: EmailClient) => emailClient.send(self.email, self.message);
In this example, each concept (message, email, email client) is defined separately and clearly, making it easy to understand and test each part. The final function, send
, naturally builds on these concepts, combining them in a straightforward way to achieve the desired outcome.
By letting concepts derive from one another, you create code that’s clean, maintainable, and ready to grow—just like stacking the perfect sandwich!
Key Takeaways:
- Readability: The code is easy to read and follow, like following a recipe.
- Testability: Each concept is isolated, making it easy to test individual components without getting tangled up in other logic.
- Scalability: New features or changes can be added without disrupting the existing structure, just like adding new ingredients to your sandwich.
6. Start with the Big Picture
Imagine you’re trying to assemble a piece of furniture. You’d expect the instructions to start with an overview: the final product image, a list of tools, and a step-by-step guide. But what if it started by explaining how to use a screwdriver, followed by a detailed description of each screw, and only at the very end showed you what you were building? That would be confusing and frustrating, right?
In coding, the same principle applies. In Clean Code, Uncle Bob states that good code should be pleasing to read. Reading it should make you smile the way a well-crafted music box or well-designed car would.
Your code should begin with the big picture—the main function—before diving into the smaller, supporting details. This way, anyone reading it can quickly grasp the purpose of the code without getting lost in the minutiae.
Here’s an example to illustrate this:
Bad Code:
// Starts with detailed helper functions
const calculateArea = (length: number, width: number): number => {
return length * width;
};
const getDimensions = (): { length: number; width: number } => {
return { length: 5, width: 10 };
};
const main = () => {
const dimensions = getDimensions();
const area = calculateArea(dimensions.length, dimensions.width);
console.log(`The area is ${area}`);
};
Good Code:
// Starts with the big picture
const main = () => {
const dimensions = getDimensions();
const area = calculateArea(dimensions.length, dimensions.width);
console.log(`The area is ${area}`);
};
const getDimensions = (): { length: number; width: number } => {
return { length: 5, width: 10 };
};
const calculateArea = (length: number, width: number): number => {
return length * width;
};
Key Takeaways:
- Start with the big picture: Kick off with the primary function or purpose of the code to give context.
- Support comes later: Place helper functions and smaller details after the main function to maintain clarity.
- Focus on readability: Organize your code like a clear set of instructions, guiding the reader through the main idea before diving into the details.
Posted on August 14, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.