Skip to main content

Writing Self-Documenting Code

Ryan Dahlberg
Ryan Dahlberg
November 12, 2025 13 min read
Share:
Writing Self-Documenting Code

Writing Self-Documenting Code

You’re debugging production at 2 AM. You open a critical function and see:

function proc(x: any, y: any): any {
  const z = x.map(a => a.b);
  return y ? z.filter(c => c > 5) : z;
}

What does this do? Why does it exist? When should you call it?

Now you’re hunting for documentation. Maybe there’s a README. Maybe there are comments. Maybe—if you’re lucky—there’s a design doc somewhere.

Or maybe the original author left six months ago and nobody knows.

Self-documenting code solves this problem.

After building systems ranging from AI orchestration platforms like Cortex to infrastructure automation, I’ve learned that the best documentation is code that doesn’t need explaining.

Let me show you how to write it.

What Is Self-Documenting Code?

Self-documenting code communicates its purpose, behavior, and constraints through:

  • Clear naming - Variables, functions, and classes explain what they are
  • Expressive structure - Code organization reveals design intent
  • Intentional design - Patterns and types prevent misuse

The goal: A developer can understand what the code does and why it exists without external documentation.

Why It Matters

The Documentation Problem

Traditional documentation has a fatal flaw: it rots.

// Documentation says one thing
/**
 * Calculates total price with tax
 * @param price - Base price
 * @returns Price including 8% sales tax
 */

// Code does another
function calculateTotal(price: number): number {
  return price * 1.13; // 13% tax now
}

The comment is wrong. How many developers will notice? How many will update it?

Self-documenting code stays synchronized because the documentation IS the code.

The Benefits

  1. Lower cognitive load - Understand code faster
  2. Fewer bugs - Intent is clear, misuse is obvious
  3. Easier onboarding - New developers get up to speed quickly
  4. Less documentation debt - No comments to maintain
  5. Better refactoring - Safe to change because intent is preserved

Principle 1: Names That Tell Stories

The Power of Naming

Consider these two implementations:

// ❌ Cryptic
function calc(n: number): number {
  return n < 100 ? n * 0.1 : n * 0.15;
}

// ✅ Self-documenting
function calculateLoyaltyDiscount(orderTotal: number): number {
  const SMALL_ORDER_THRESHOLD = 100;
  const SMALL_ORDER_DISCOUNT_RATE = 0.1;
  const LARGE_ORDER_DISCOUNT_RATE = 0.15;

  const isSmallOrder = orderTotal < SMALL_ORDER_THRESHOLD;
  const discountRate = isSmallOrder
    ? SMALL_ORDER_DISCOUNT_RATE
    : LARGE_ORDER_DISCOUNT_RATE;

  return orderTotal * discountRate;
}

The second version tells you:

  • What it calculates (loyalty discount)
  • Why the threshold exists (small vs large orders)
  • How the calculation works (percentage-based)

Naming Guidelines

Variables: Describe the Content

// ❌ Vague
const data = await fetch('/api/users');
const result = process(data);
const output = transform(result);

// ✅ Descriptive
const userList = await fetch('/api/users');
const activeUsers = filterActiveUsers(userList);
const userDashboardData = transformForDashboard(activeUsers);

Functions: Verb + Noun

// ❌ Unclear
function user(id: string) { ... }
function valid(email: string) { ... }
function process(data: any) { ... }

// ✅ Clear action
function fetchUser(id: string) { ... }
function isValidEmail(email: string) { ... }
function transformCustomerData(data: CustomerData) { ... }

Booleans: Ask a Question

// ❌ Ambiguous
let status = true;
let premium = false;
let access = true;

// ✅ Clear intent
let isActive = true;
let hasPremiumSubscription = false;
let canAccessAdminPanel = true;

Classes: Nouns That Describe Responsibility

// ❌ Generic
class Manager { ... }
class Handler { ... }
class Processor { ... }

// ✅ Specific
class UserAuthenticationService { ... }
class EmailDeliveryQueue { ... }
class PaymentTransactionProcessor { ... }

Context-Dependent Naming

Names should be clear in context:

// In a user service, this is clear
class UserService {
  async create(userData: UserData) { ... }  // Clearly creates a user
  async delete(id: string) { ... }          // Clearly deletes a user
}

// At the top level, be more specific
async function createUser(userData: UserData) { ... }
async function deleteUser(id: string) { ... }

Principle 2: Structure Reveals Intent

Extract Named Functions

Replace comments with well-named functions:

// ❌ Comments explain logic
function processOrder(order: Order) {
  // Validate order has required fields
  if (!order.items || order.items.length === 0) {
    throw new Error('Order must have items');
  }

  // Calculate total with tax
  const subtotal = order.items.reduce((sum, item) => sum + item.price, 0);
  const tax = subtotal * 0.08;
  const total = subtotal + tax;

  // Apply discount for premium users
  if (order.user.isPremium) {
    total = total * 0.9;
  }

  return total;
}

// ✅ Functions explain logic
function processOrder(order: Order): number {
  validateOrderHasItems(order);

  const subtotal = calculateSubtotal(order.items);
  const total = addSalesTax(subtotal);
  const finalTotal = applyPremiumDiscount(total, order.user);

  return finalTotal;
}

function validateOrderHasItems(order: Order): void {
  if (!order.items || order.items.length === 0) {
    throw new Error('Order must have items');
  }
}

function calculateSubtotal(items: OrderItem[]): number {
  return items.reduce((sum, item) => sum + item.price, 0);
}

function addSalesTax(subtotal: number): number {
  const TAX_RATE = 0.08;
  return subtotal * (1 + TAX_RATE);
}

function applyPremiumDiscount(total: number, user: User): number {
  const PREMIUM_DISCOUNT_RATE = 0.1;
  return user.isPremium
    ? total * (1 - PREMIUM_DISCOUNT_RATE)
    : total;
}

Now the high-level logic reads like prose.

The Guard Clause Pattern

Replace nested ifs with early returns:

// ❌ Nested conditionals hide the main logic
function getDiscount(user: User, orderTotal: number): number {
  if (user) {
    if (user.isPremium) {
      if (orderTotal > 100) {
        return 0.2;
      } else {
        return 0.1;
      }
    } else {
      return 0.05;
    }
  } else {
    return 0;
  }
}

// ✅ Guard clauses reveal the happy path
function getDiscount(user: User, orderTotal: number): number {
  if (!user) return 0;
  if (!user.isPremium) return 0.05;

  const LARGE_ORDER_THRESHOLD = 100;
  const LARGE_ORDER_PREMIUM_DISCOUNT = 0.2;
  const SMALL_ORDER_PREMIUM_DISCOUNT = 0.1;

  return orderTotal > LARGE_ORDER_THRESHOLD
    ? LARGE_ORDER_PREMIUM_DISCOUNT
    : SMALL_ORDER_PREMIUM_DISCOUNT;
}

Symmetry and Patterns

Use consistent patterns to signal relationships:

// ❌ Inconsistent
function fetchUser(id: string) { ... }
function createNewUserRecord(data: UserData) { ... }
function userUpdate(id: string, changes: Partial<User>) { ... }
function removeUserFromDatabase(id: string) { ... }

// ✅ Consistent CRUD pattern
function fetchUser(id: string) { ... }
function createUser(data: UserData) { ... }
function updateUser(id: string, changes: Partial<User>) { ... }
function deleteUser(id: string) { ... }

Principle 3: Types as Documentation

Make Invalid States Unrepresentable

Use types to enforce constraints:

// ❌ Allows invalid states
interface User {
  email?: string;
  isGuest: boolean;
}

// What if email is missing but isGuest is false?

// ✅ Invalid states are impossible
type User =
  | { type: 'guest' }
  | { type: 'authenticated'; email: string };

function sendEmail(user: User) {
  if (user.type === 'guest') {
    throw new Error('Cannot email guest users');
  }

  // TypeScript knows email exists here
  emailService.send(user.email, 'Welcome!');
}

Semantic Types Over Primitives

Create types that express domain meaning:

// ❌ Everything is a string
function transferMoney(from: string, to: string, amount: string) {
  // Is 'from' a user ID? Account number? Email?
  // Is 'amount' in dollars? Cents? Formatted?
}

// ✅ Types clarify meaning
type UserId = string & { readonly __brand: 'UserId' };
type AccountId = string & { readonly __brand: 'AccountId' };
type DollarAmount = number & { readonly __brand: 'DollarAmount' };

function transferMoney(
  fromAccount: AccountId,
  toAccount: AccountId,
  amount: DollarAmount
): void {
  // Intent is crystal clear
}

Exhaustive Matching

Let TypeScript ensure all cases are handled:

type PaymentStatus =
  | { type: 'pending' }
  | { type: 'processing'; transactionId: string }
  | { type: 'completed'; completedAt: Date }
  | { type: 'failed'; errorMessage: string };

function getStatusMessage(status: PaymentStatus): string {
  switch (status.type) {
    case 'pending':
      return 'Payment is pending';
    case 'processing':
      return `Processing transaction ${status.transactionId}`;
    case 'completed':
      return `Completed at ${status.completedAt.toISOString()}`;
    case 'failed':
      return `Failed: ${status.errorMessage}`;
    // TypeScript error if we forget a case!
  }
}

Principle 4: Express Intent, Not Implementation

Declarative Over Imperative

// ❌ Imperative: HOW to do it
function getActiveUserEmails(users: User[]): string[] {
  const emails: string[] = [];

  for (let i = 0; i < users.length; i++) {
    const user = users[i];
    if (user.isActive) {
      emails.push(user.email);
    }
  }

  return emails;
}

// ✅ Declarative: WHAT we want
function getActiveUserEmails(users: User[]): string[] {
  return users
    .filter(user => user.isActive)
    .map(user => user.email);
}

Named Constants Over Magic Numbers

// ❌ Magic numbers
function canVote(age: number): boolean {
  return age >= 18;
}

function canRentCar(age: number): boolean {
  return age >= 25;
}

setTimeout(fetchData, 5000);

// ✅ Named constants explain meaning
const VOTING_AGE = 18;
const CAR_RENTAL_AGE = 25;
const DATA_REFRESH_INTERVAL_MS = 5000;

function canVote(age: number): boolean {
  return age >= VOTING_AGE;
}

function canRentCar(age: number): boolean {
  return age >= CAR_RENTAL_AGE;
}

setTimeout(fetchData, DATA_REFRESH_INTERVAL_MS);

Principle 5: Scope and Context

// ❌ Related logic scattered
const TAX_RATE = 0.08;

function calculateSubtotal(items: Item[]): number {
  return items.reduce((sum, item) => sum + item.price, 0);
}

// 100 lines later...

function calculateTotal(items: Item[]): number {
  const subtotal = calculateSubtotal(items);
  return subtotal * (1 + TAX_RATE);
}

// ✅ Related logic grouped
class OrderCalculator {
  private static readonly TAX_RATE = 0.08;

  static calculateSubtotal(items: Item[]): number {
    return items.reduce((sum, item) => sum + item.price, 0);
  }

  static calculateTotal(items: Item[]): number {
    const subtotal = this.calculateSubtotal(items);
    return subtotal * (1 + this.TAX_RATE);
  }
}

Limit Scope

// ❌ Broad scope invites misuse
let userEmail = '';

function fetchUserData(id: string) {
  const user = db.getUser(id);
  userEmail = user.email; // Modifying global state
  return user;
}

function sendNotification() {
  emailService.send(userEmail, 'Hello'); // Depends on global state
}

// ✅ Narrow scope prevents misuse
function fetchUserData(id: string): User {
  return db.getUser(id);
}

function sendNotification(userEmail: string): void {
  emailService.send(userEmail, 'Hello');
}

// Usage is explicit
const user = fetchUserData('123');
sendNotification(user.email);

When Comments Are Necessary

Self-documenting code doesn’t mean zero comments. Use comments for:

Why, Not What

// ❌ Comment states the obvious
// Increment counter
counter++;

// ✅ Comment explains the reasoning
// Increment counter before check to prevent off-by-one error
// in subsequent loop iteration
counter++;

Business Rules and Constraints

// ✅ Document external constraints
// Per IRS regulations, tax-exempt orgs are identified by EIN prefix 93
const TAX_EXEMPT_EIN_PREFIX = '93';

// ✅ Document quirks and workarounds
// Safari doesn't support smoothscroll, use polyfill
// https://github.com/iamdustan/smoothscroll/issues/47
if (!('scrollBehavior' in document.documentElement.style)) {
  await import('smoothscroll-polyfill');
}

Complex Algorithms

// ✅ Explain non-obvious algorithms
/**
 * Uses Levenshtein distance to find closest match.
 * This O(n*m) algorithm is acceptable here because:
 * 1. Suggestion lists are max 100 items
 * 2. User input is max 50 chars
 * 3. Runs on keyup debounced by 300ms
 */
function findClosestMatch(input: string, options: string[]): string {
  // Implementation...
}

Real-World Example

Let’s refactor a real function from unclear to self-documenting:

Before: Cryptic ❌

function calc(u: any, o: any): number {
  let t = 0;
  for (let i = 0; i < o.items.length; i++) {
    t += o.items[i].p * o.items[i].q;
  }
  if (u.pm) t *= 0.9;
  if (t > 100) t -= 10;
  return t;
}

After: Self-Documenting ✅

interface User {
  isPremiumMember: boolean;
}

interface OrderItem {
  price: number;
  quantity: number;
}

interface Order {
  items: OrderItem[];
}

function calculateOrderTotal(user: User, order: Order): number {
  const subtotal = calculateItemsSubtotal(order.items);
  const afterMemberDiscount = applyPremiumMemberDiscount(subtotal, user);
  const finalTotal = applyBulkOrderDiscount(afterMemberDiscount);

  return finalTotal;
}

function calculateItemsSubtotal(items: OrderItem[]): number {
  return items.reduce((total, item) => {
    return total + (item.price * item.quantity);
  }, 0);
}

function applyPremiumMemberDiscount(subtotal: number, user: User): number {
  const PREMIUM_DISCOUNT_RATE = 0.1;

  return user.isPremiumMember
    ? subtotal * (1 - PREMIUM_DISCOUNT_RATE)
    : subtotal;
}

function applyBulkOrderDiscount(total: number): number {
  const BULK_ORDER_THRESHOLD = 100;
  const BULK_ORDER_DISCOUNT = 10;

  return total > BULK_ORDER_THRESHOLD
    ? total - BULK_ORDER_DISCOUNT
    : total;
}

What improved:

  • Type safety (User, Order, OrderItem interfaces)
  • Descriptive names (calculateOrderTotal vs calc)
  • Single responsibility (each function does one thing)
  • Named constants (PREMIUM_DISCOUNT_RATE vs 0.9)
  • Guard clauses (ternary operators for clarity)

Common Objections

”This makes the code longer”

Yes, and that’s okay.

// Shorter but unclear
const x = users.filter(u => u.a && !u.d).map(u => u.e);

// Longer but clear
const activeUsers = users.filter(user => user.isActive && !user.isDeleted);
const activeUserEmails = activeUsers.map(user => user.email);

Would you rather debug 5 cryptic lines or 10 clear lines?

”Naming is hard”

Yes. That’s why it matters.

If you can’t name something clearly, you probably don’t understand it well enough yet.

Struggling to name a function? Ask:

  • What does it do?
  • Why does it exist?
  • When should it be called?

The answers become the name.

”This is slower”

Modern JavaScript engines optimize this away.

// You think this is faster
const t = arr.filter(x => x > 5).map(x => x * 2)[0];

// It's the same performance after JIT compilation
const numbersAboveFive = arr.filter(num => num > 5);
const doubledNumbers = numbersAboveFive.map(num => num * 2);
const firstResult = doubledNumbers[0];

Premature optimization is the root of all evil. Write clear code first, optimize bottlenecks later.

Practical Action Plan

Week 1: Audit Current Code

Review your recent PRs:

  1. Find 5 unclear variable names → Rename them
  2. Find 3 functions that need comments to understand → Refactor them
  3. Find 2 magic numbers → Extract to named constants

Week 2: Practice New Habits

For every new function you write:

  1. Can you name it clearly with a verb + noun?
  2. Does it do exactly one thing?
  3. Are all variables descriptive?
  4. Are edge cases handled explicitly?

Week 3: Refactor Incrementally

Pick one module to improve:

  1. Extract inline logic to named functions
  2. Replace comments with better names
  3. Add types where missing
  4. Group related code together

Ongoing: Code Review Checklist

Ask these questions during review:

  • Would a new team member understand this without asking?
  • Do names reveal intent?
  • Could you remove any comments by improving code?
  • Are types helping or obscuring meaning?

Conclusion: Code as Communication

Remember: You write code for humans, not computers.

The computer doesn’t care if your variable is called x or userEmailAddress. It executes the same.

But the human debugging production at 2 AM? They care deeply.

Self-documenting code is an act of empathy:

  • For your teammates who will maintain it
  • For your future self who will debug it
  • For the new hire who will extend it

Good code reads like a well-written book. The plot is clear, the characters are memorable, and you understand what’s happening without constant re-reading.

That’s the standard we should aim for.

Resources


Part of the Developer Skills series. Writing code that speaks for itself.

What’s your biggest challenge with code clarity? Do you have techniques for self-documenting code that I didn’t cover? I’d love to hear about them!

#Code Quality #Clean Code #Readability #Best Practices #Documentation