DEV Community

Cover image for Error Handling in CLI Tools: A Practical Pattern That’s Worked for Me
Chloe Zhou
Chloe Zhou

Posted on

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

Originally published on Medium.

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.

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

The command handler then handles all errors in one place:

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

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.

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

Then every caller must check the returned object explicitly:

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

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.

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

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.

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

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.

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

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.

1. Low-Level: Throw with Context

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

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.

2. Business Logic Layer: Enforce Rules

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

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.

3. Command Layer: Catch + Present

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

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. The current version stores notes locally and is already usable.

You can try it now via:

npm install -g czhou-notes-cli
Enter fullscreen mode Exit fullscreen mode

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 or just let me know — I'd love to hear your thoughts!

Thanks for reading 😊

Top comments (0)