# Error Handling in CLI Tools: A Practical Pattern That’s Worked for Me

> Originally written in 2025. Content may vary slightly across newer versions.

### **Error Handling in CLI Tools: A Practical Pattern That’s Worked for Me**

I’ve been building a small CLI tool recently to help manage personal notes from the terminal. It’s a simple project, but adding features like persistent user sessions and database access made me think more seriously about error handling.

In particular, I wanted to find a balance between surfacing helpful messages to users while keeping my codebase clean and predictable. This post documents the approach I landed on, why I chose it, and how it plays out in a few real command implementations.

#### Why Error Handling Matters in CLI Tools

When designing error handling for a CLI tool, my goal was to make sure that any failure a user runs into is:

*   **Human-readable**
    
*   **Actionable**
    
*   **Context-aware**
    

To get there, I explored common error handling patterns in async JavaScript — specifically how to structure error throwing in utility functions versus catching in command handlers, and how to categorize different types of errors. I ended up with an approach that distinguishes between **expected errors**, **system errors**, and **business logic errors**.

#### Two Common Patterns

*   **Pattern 1: Throw Errors** (Recommended for CLI)
    
*   **Pattern 2: Return Error Objects**
    

Let me show you what they look like in practice.

#### Pattern 1: Throw Errors (Recommended for CLI)

This pattern has low-level functions throw errors when something goes wrong. The errors **bubble up** to the command handler, which catches them and displays a friendly message.

```javascript
export const saveUserSession = async (user) => {
  try {
    await ensureUserDir();
    const sessionData = { 
      id: user.id, 
      username: user.username, 
      loginTime: new Date().toISOString() 
    };
    await writeFile(
      USER_SESSION_PATH, 
      JSON.stringify(sessionData, null, 2), 
      'utf-8'
    );
    return sessionData;
  } catch (error) {
    throw new Error(
      `Could not save user session: ${error.message}`
    );
  }
};
```

The command handler then handles all errors in one place:

```javascript
.command(
  'setup <username>', 
  'Setup user', 
  {}, 
  async (argv) => {
  try {
    const user = await findOrCreateUser(argv.username);
    await saveUserSession(user);
    console.log(
      `✅ Successfully logged in as: ${user.username}`
    );
  } catch (error) {
    console.error('❌', error.message);
    process.exit(1);
  }
});
```

This approach keeps the command code clean and focused. You only deal with errors **once**, and you get to present **consistent** messages.

#### Pattern 2: Return Error Objects (Alternative)

Here, low-level functions catch errors themselves and return objects indicating success or failure.

```javascript
export const saveUserSession = async (user) => {
  try {
    await ensureUserDir();
    const sessionData = { 
      id: user.id, 
      username: user.username, 
      loginTime: new Date().toISOString() 
    };
    await writeFile(
      USER_SESSION_PATH, 
      JSON.stringify(sessionData, null, 2), 
      'utf-8'
    );
    return { success: true, data: sessionData };
  } catch (error) {
    return { 
      success: false, 
      error: `Could not save user session: ${error.message}` 
    };
  }
};
```

Then every caller must check the returned object explicitly:

```javascript
.command(
  'setup <username>', 
  'Setup user', 
  {}, 
  async (argv) => {
  const userResult = await findOrCreateUser(argv.username);
  if (!userResult.success) {
    console.error('❌', userResult.error);
    process.exit(1);
    return;
  }

  const sessionResult = await saveUserSession(userResult.data);
  if (!sessionResult.success) {
    console.error('❌', sessionResult.error);
    process.exit(1);
    return;
  }

  console.log(
    `✅ Successfully logged in as: ${userResult.data.username}`
  );
});

```

While this pattern makes errors explicit, it can lead to repetitive and verbose code, especially in command handlers.

#### Why I Prefer Pattern 1 (Throw Errors)

This pattern feels like a better fit for CLI tools:

*   **Low-level modules** throw meaningful errors when things go wrong
    
*   **Errors bubble up** automatically through the call stack
    
*   **Top-level command handlers** catch them once and show user-friendly messages
    
*   **Exit codes** tell the shell that something failed
    

This keeps responsibilities clear: helper functions focus on their job, command handlers focus on user communication.

#### Error Handling Strategy by Type

#### 1\. Expected “Errors” (Not Really Errors)

Some conditions aren’t really errors — they’re just normal edge cases that we expect to happen occasionally. For example, if there’s no session file, that simply means the user hasn’t logged in yet.

```javascript
export const getUserSession = async () => {
  try {
    await access(USER_SESSION_PATH);
    const sessionData = await readFile(
        USER_SESSION_PATH, 
        'utf-8'
    );
    return JSON.parse(sessionData);
  } catch (error) {
    if (error.code === 'ENOENT') {
      return null; 
      // File doesn't exist = no session (EXPECTED)
    }
    throw error; // Unexpected error
  }
};
```

#### 2\. System Errors

These usually come from the underlying platform — e.g. Node.js APIs, the file system, or corrupted files. They’re rare but should be surfaced with context.

```javascript
export const saveUserSession = async (user) => {
  try {
    await writeFile(
      USER_SESSION_PATH,                            
      JSON.stringify(sessionData)
    );
    return sessionData;
  } catch (error) {
    // Transform technical error into user-friendly message
    throw new Error(
      `Could not save user session: ${error.message}`
    );
  }
};
```

#### 3\. Business Logic Errors

These happen when users violate your application’s rules or skip required steps. The system works fine, but the user needs to do something differently.

```javascript
export const requireUserSession = async () => {
  const session = await getUserSession();
  if (!session) {
    // This is a business rule violation
    throw new Error(
      'No user session found. Please run "note setup <username>" first.'
    );
  }
  return session;
};
```

**The Key Insight**

Notice how each type gets handled differently:

**Expected conditions** → Return `null` or default values, don’t throw

**System errors** → Wrap with context, then throw

**Business logic errors** → Throw with clear instructions for the user

This approach means your command handlers can catch everything with one `try/catch`, but users get appropriate messages for each situation.

#### Complete Error Flow Example

Let’s walk through how the full error handling flow works — from throwing to catching to presenting.

**Low-Level: Throw with Context**

```javascript
export const clearUserSession = async () => {
  try {
    await unlink(USER_SESSION_PATH);
    return true;
  } catch (error) {
    if (error.code === 'ENOENT') {
      return true; 
      // File doesn't exist = mission accomplished anyway
    }
    throw new Error(
      `Failed to clear session: ${error.message}`
    );
  }
};
```

At this level, we care about *what* failed, not *how* to explain it to the user. We handle the expected case (no file) and throw system errors with context.

**Business Logic Layer: Enforce Rules**

```javascript
export const requireUserSession = async () => {
  const session = await getUserSession();
  if (!session) {
    throw new Error(
      'No user session found. Please run "note setup <username>" first.'
    );
  }
  return session;
};
```

This enforces a business rule: “You must be logged in to logout.” We throw a specific message that tells the user exactly what to do.

**Command Layer: Catch + Present**

```javascript
.command(
  'logout', 
  'Clear current user session', 
  {}, 
  async () => {
  try {
    // Business rule check
    const session = await requireUserSession(); 
    await clearUserSession(); // Low-level operation  
    console.log(
      `✓ Logged out ${session.username} successfully.`
    );
  } catch (error) {
    console.error('❌', error.message);
    process.exit(1);
  }
});
```

This is the one place where we actually *talk* to the user. We catch everything, show a friendly message, and exit with a non-zero code to signal failure.

Now it’s a true connected flow: check session → clear session → report success, with proper error handling at each layer!

#### Best Practices Summary

*   **Low-level functions**: throw meaningful errors with context, handle expected cases gracefully (like `ENOENT` → return success)
    
*   **Expected cases**: don’t throw for normal situations — return appropriate values instead
    
*   **Business logic violations**: throw with clear, actionable messages that tell users what to do next
    
*   **Command handlers**: catch all errors in one place, present friendly feedback, and call `process.exit(1)` for failures
    
*   **Error messages**: be specific and actionable — tell users exactly what went wrong and how to fix it
    
*   **Exit codes**: use `process.exit(1)`so scripts and shells know something failed
    

#### Try It Out (And Stay Tuned)

The error handling strategies in this post are part of a broader upgrade I’m working on for my CLI tool [czhou-notes-cli](https://www.npmjs.com/package/czhou-notes-cli). The current version stores notes locally and is already usable.

You can try it now via:

```typescript
npm install -g czhou-notes-cli
```

Right now I’m actively improving it — adding things like database support and smoother command experience — and this error handling refactor is just one piece of the puzzle.

If you give it a try and have any ideas or suggestions, feel free to [open an issue](https://github.com/chloezhoudev/czhou-notes-cli/issues) or just let me know — I’d love to hear your thoughts!

Thanks for reading 😊
