As developers, we've all wasted hours, staring at a piece of code, trying to decipher its purpose, and wondering what the original author was thinking. In the world of software development, where projects change hands and codebases evolve quickly, writing self-documenting code is not just a nice-to-have; it's a necessity.
In this article, we'll explore the art of crafting code that speaks for itself, reducing the reliance on external documentation and making life easier for yourself and your fellow developers.
What is Self-Documenting Code?
Self-documenting code is code that is written in a clear, expressive, and intentional manner, making its purpose and functionality easily understandable without the need for extensive comments or external documentation.
It's about writing code that is:
- Readable: Code that is easy to read and understand at a glance
- Expressive: Code that clearly conveys its intent and purpose
- Maintainable: Code that is easy to modify and update without introducing bugs or breaking changes
Why Self-Documenting Code Matters
Writing self-documenting code offers several benefits:
Reduced Cognitive Load: When code is self-explanatory, developers can quickly grasp its purpose and functionality, reducing the mental effort required to understand and work with the codebase.
Faster Onboarding: New team members can get up to speed more quickly when the codebase is self-documenting, as they don't need to rely heavily on external documentation or extensive knowledge transfer sessions.
Improved Collaboration: Self-documenting code facilitates better collaboration among team members, as it minimizes misunderstandings and promotes a shared understanding of the codebase.
Enhanced Maintainability: When code is self-documenting, it's easier to maintain and update over time, as developers can quickly understand the existing code and make informed changes.
Techniques for Writing Self-Documenting Code
Let's explore some practical techniques for writing self-documenting code, with a focus on TypeScript:
1. Use Meaningful Names
One of the most effective ways to make your code self-documenting is to use meaningful names for variables, functions, classes, and modules. Consider the following example:
// Bad
const x = 5;
const y = 10;
const z = x + y;
// Good
const numberOfItems = 5;
const itemPrice = 10;
const totalCost = numberOfItems * itemPrice;
In the "Good" example, the variable names clearly convey their purpose, making the code more readable and self-explanatory.
2. Write Small, Focused Functions
Writing small, focused functions is another key aspect of self-documenting code. Functions should have a single responsibility and be named accurately to reflect their purpose. For example:
// Bad
function processData(data: any): any {
// ...
// Lots of complex logic
// ...
return result;
}
// Good
function extractRelevantFields(data: Record<string, any>): Record<string, any> {
// ...
return relevantFields;
}
function applyBusinessRules(relevantFields: Record<string, any>): Record<string, any> {
// ...
return processedData;
}
function formatOutput(processedData: Record<string, any>): string {
// ...
return formattedResult;
}
By breaking down a large function into smaller, focused functions with descriptive names, the code becomes more readable and self-documenting.
3. Use Descriptive Function and Method Names
When naming functions and methods, use descriptive names that clearly convey their purpose and action. Avoid generic names like handle()
or process()
. Instead, opt for names that describe what the function does. For example:
// Bad
function handleInput(input: string): void {
// ...
}
// Good
function validateUserCredentials(username: string, password: string): boolean {
// ...
}
The descriptive name validateUserCredentials
makes it clear what the function does without the need for additional comments.
4. Leverage TypeScript's Type System
TypeScript's powerful type system can greatly enhance the self-documenting nature of your code. By leveraging TypeScript's features, you can make your code more expressive and catch potential errors early. For example:
- Interfaces and Types: Use interfaces and custom types to define the shape of your data structures, making the code more readable and self-explanatory.
interface User {
id: number;
name: string;
email: string;
}
function getUserById(id: number): User | undefined {
// ...
}
- Enums: Utilize enums to represent a fixed set of values, providing a clear and readable way to handle different scenarios.
enum PaymentStatus {
Pending = 'pending',
Completed = 'completed',
Failed = 'failed',
}
function processPayment(status: PaymentStatus): void {
// ...
}
- Type Inference: Let TypeScript infer types whenever possible, as it reduces verbosity and makes the code more readable.
// Bad
const count: number = 10;
const message: string = 'Hello, world!';
// Good
const count = 10;
const message = 'Hello, world!';
5. Use Strongly Typed IDs
When working with IDs in TypeScript, consider using strongly typed IDs instead of simple strings or numbers. Strongly typed IDs provide additional type safety and make the code more self-documenting.
One way to implement strongly typed IDs is by using opaque types:
// user.ts
export type UserId = string & { readonly __brand: unique symbol };
export function createUserId(id: string): UserId {
return id as UserId;
}
// post.ts
export type PostId = string & { readonly __brand: unique symbol };
export function createPostId(id: string): PostId {
return id as PostId;
}
By using strongly typed IDs, you can ensure that a UserId can only be assigned to properties or functions expecting a UserId, and a PostId can only be assigned to properties or functions expecting a PostId.
function getUserById(userId: UserId): User | undefined {
// ...
}
const userId = createUserId('user-123');
const postId = createPostId('post-456');
getUserById(userId); // No error
getUserById(postId); // Error: Argument of type 'PostId' is not assignable to parameter of type 'UserId'.
Strongly typed IDs help catch potential errors at compile-time and make the code more expressive and self-documenting. However, they do introduce some overhead compared to using simple string types. Consider the trade-offs based on your project's needs and scale.
6. Establish Consistency and Set Expectations in a Team
When working on a codebase as part of a team, establishing consistency and setting clear expectations is crucial. This helps ensure that everyone is on the same page and follows the same conventions, making the code more readable and maintainable.
One important aspect of consistency is naming conventions. Establish a style guide that defines how variables, functions, classes, and other entities should be named. For example, consider the following terms and their meanings:
-
get
: Retrieves a single value from an API or data source. -
list
: Retrieves a set of values from an API or data source. -
patch
: Partially updates an existing entity or object. -
upsert
: Updates an existing entity or inserts a new one if it - doesn't exist.
By defining these terms and their usage, you can ensure consistency across the codebase. For example:
function getUser(userId: UserId): Promise<User> {
// ...
}
function listUsers(): Promise<User[]> {
// ...
}
function patchUser(userId: UserId, updatedData: Partial<User>): Promise<User> {
// ...
}
Consistency helps make the codebase more predictable and easier to understand for all team members. In addition to naming conventions, consider establishing guidelines for other aspects of the codebase, such as file and folder structure, comments, error handling, testing, and code formatting.
When working in a team, you might not always personally agree with all of the conventions that are being enforced. However, it's important to remember that consistency and collaboration are crucial for the success of the project. Even if you have a different preference or coding style, adhering to the agreed conventions helps maintain a cohesive codebase and reduces confusion among other team members.
7. Use JSDoc or TSDoc for Complex Scenarios
While self-documenting code should be the primary goal, there may be cases where additional documentation is necessary, such as complex algorithms or intricate business logic. In such scenarios, consider using JSDoc or TSDoc to provide clear and concise documentation.
/**
* Calculates the Fibonacci number at the given position.
*
* @param {number} position - The position of the Fibonacci number to calculate.
* @returns {number} The Fibonacci number at the specified position.
*/
function fibonacci(position: number): number {
if (position <= 1) {
return position;
}
return fibonacci(position - 1) + fibonacci(position - 2);
}
By using JSDoc or TSDoc, you can provide additional context and explanations for complex code without cluttering the codebase itself.
Conclusion
Writing self-documenting code is an art that every developer should strive to master. By leveraging meaningful names, small and focused functions, TypeScript's type system, and judicious use of documentation, you can create code that is readable, expressive, and maintainable.
Remember, the goal is to write code that speaks for itself, reducing the reliance on external documentation and making the lives of your fellow developers easier. So, the next time you're writing code, take a moment to consider how you can make it more self-documenting. Your future self and your teammates will thank you!
Also, shameless plug π. If you work in an agile dev team and use tools for your online meetings like planning poker or retrospectives, check out my free tool called Kollabe!
Top comments (29)
Other that JSDoc/TSDoc I am in full agreement. Excellent article.
But I would also mention that consistency in naming is important. Establish a style guide and define your terms so everyone is on the same page.
For example, consider these terms. What do they mean and how do you choose between them:
get
,find
,make
,create
,choose
,select
,send
,submit
,save
, etc.Perhaps
get
means you are retrieving a single value from an API andlist
means you are retrieving a set of values. Then every function that retrieves a single value should be namedget<noun>
and the corresponding multiple values function,list<noun>s
, e.g.,Great point, thanks! I've added a new section that covers this:
Establish Consistency and Set Expectations in a Team
.Incredible advice. Im currently dealing with a codebase where there are several functions, addItem, saveItem, createItem all in the same app service and they all actually do the same thing with slight variations.
Yep. The secret to programming is a) to break the problem down into small, easily-solvable mini-problems, then solve those in the simplest, clearest, and most consistent way, then compose the small solutions back into the overall solution. Clear style guides followed faithfully and invariably are essential, but how many companies bother? Save a penny, lose a pound.
All of this boils down to one simple thing: reduce cognitive load. Reduce cognitive load. Reduce cognitive load.
To this end, it pays also to apply design principles to your code. For example: proximity, alignment, contrast, repetition (consistency), whitespace, etc. I discuss this and more on Craft Code as well as here on Dev.to.
ss
Self documenting code is a fine standard to follow, but you should still have code comments. I've worked with multiple developers who said "my code is self documenting so why comment?" π€£ Then when they leave, nobody knows WHY they were doing the things they did, regardless of how self documenting the code is.
Please comment your code as well.
The need for comments in code is the lack of tests. Comments rot very fast. Uncle Bob has excellent videos on the matter.
Agreed, but IMHO, only when there is a potential "why?" question that needs to be answered.
I usually use linters as a great way to enforce this. Generally speaking, I only ever set linters to error if the code doesn't follow standards.
If the person has a reason to break standards, they need to put in a linter comment to allow the code that breaks the pattern and they are required to provide a reason that they are breaking the lint pattern. For those that can't be linted, we handle in PR. If you're doing something that is not obvious and you can't make it obvious, then you must provide a comment clarifying the issue and why you're doing it that way.
Otherwise, you should only ever use doc comments.
Comments should never describe what you are doing, nor how, only why and only when it's not obvious.
Exactly. The code can only tell you what it does. Code comments are there to tell you what it's SUPPOSED to do.
Very nice work, Matt! Thanks for the post.
Cool, nice article @mattlewandowski93 !
Nice one, but I suggest for you to include using of strongly typed IDs.
Thanks, great suggestions I'll add it in
Wow.. Really interesting, thank u mateβ€οΈ
Good article, though I would disagree with using Enums in Typescript. The thing is that they are kind of funky and don't translate in and out of the underlying type well. It can better be expressed as:
`export const PaymentStatus {
Pending = 'pending',
Completed = 'completed',
Failed = 'failed',
} as const;
export type PaymentStatus = (typeof PaymentStatus)[keyof PaymentStatus];
`
I would urge caution about breaking a large function into too many smaller functions just for the sake of making it smaller. It can lead to too much abstraction that becomes less readable because you are trying to follow the code around separate, spread out methods and files etc.
It's a great explanation of the principle of "create a meaningful standard language", which is one of the principles that programmers should follow.
However, there are still missing parts. In particular, there is a need to add meaningful comment statements next to the codes and comment notations.
Comment expression notations provide the user with mobility for immediate intervention on faulty points.
Markdown Literate programming that don't break the syntax of any programming language
Live preview in Notepad++
"Java JEP 467: Markdown Documentation Comments" uses my original method on 2019-02-17