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}`);
}
};
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);
}
});
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}` };
}
};
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}`);
});
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
}
};
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}`);
}
};
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;
};
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}`);
}
};
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;
};
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);
}
});
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
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)