DEV Community

Cover image for Writing Self-Documenting Code 🧑‍💻📜
Matt Lewandowski
Matt Lewandowski

Posted on • Edited on

Writing Self-Documenting Code 🧑‍💻📜

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:

  1. 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.

  2. 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.

  3. Improved Collaboration: Self-documenting code facilitates better collaboration among team members, as it minimizes misunderstandings and promotes a shared understanding of the codebase.

  4. 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;
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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 {
    // ...
}
Enter fullscreen mode Exit fullscreen mode
  • 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 {
    // ...
}
Enter fullscreen mode Exit fullscreen mode
  • 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!';
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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'.
Enter fullscreen mode Exit fullscreen mode

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> {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
chasm profile image
Charles F. Munat

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 and list means you are retrieving a set of values. Then every function that retrieves a single value should be named get<noun> and the corresponding multiple values function, list<noun>s, e.g.,

getUsername()
listUsers()
Enter fullscreen mode Exit fullscreen mode
Collapse
 
mattlewandowski93 profile image
Matt Lewandowski

Great point, thanks! I've added a new section that covers this: Establish Consistency and Set Expectations in a Team.

Collapse
 
z2lai profile image
z2lai

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.

Collapse
 
chasm profile image
Charles F. Munat

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.

Collapse
 
thirumalesh profile image
thirumaleshthiru

ss

Collapse
 
ausmurp profile image
Austin Murphy

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.

Collapse
 
retakenroots profile image
Rene Kootstra

The need for comments in code is the lack of tests. Comments rot very fast. Uncle Bob has excellent videos on the matter.

Collapse
 
chris_sd_b9194652dbd4a1e profile image
Chris S-D

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.

Collapse
 
jamescurran profile image
James Curran

Exactly. The code can only tell you what it does. Code comments are there to tell you what it's SUPPOSED to do.

Collapse
 
documendous profile image
Documendous

Very nice work, Matt! Thanks for the post.

Collapse
 
ricardogesteves profile image
Ricardo Esteves

Cool, nice article @mattlewandowski93 !

Collapse
 
ghadzhigeorgiev profile image
Georgi Hadzhigeorgiev

Nice one, but I suggest for you to include using of strongly typed IDs.

Collapse
 
mattlewandowski93 profile image
Matt Lewandowski

Thanks, great suggestions I'll add it in

Collapse
 
shyam_10 profile image
Shyam raj

Wow.. Really interesting, thank u mate❤️

Collapse
 
kwoodgmr profile image
kwood-gmr

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];
`

Collapse
 
gokayburuc profile image
gokayburuc.dev

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.

// TODO :
//REVIEW :
// FIX :
// TEST :
// OPTIMIZE :
// NOTE:
// HACK:
Enter fullscreen mode Exit fullscreen mode

Comment expression notations provide the user with mobility for immediate intervention on faulty points.

Collapse
 
jakewilson profile image
Jake Wilson

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.

Collapse
 
itsmeseb profile image
sebkolind

Great article! I think there are a lot of good ideas and valid points. I am not a fan of (2.) though - it seems like taken right out of the "Clean Code" book. It's a pain jumping from function to function to function, and you most likely lose context. I would like a bigger function where the entire context is visible and ready for me to read.