Tutorial

How to Handle CLI Errors and Exit Codes

Handle errors in a CLI with typed error classes and matching exit codes, so the user sees a helpful message and scripts can branch.

On this page

  1. Introduction
  2. Define error classes
  3. Map to exit codes
  4. Print user-friendly msg
  5. Log stack in verbose
  6. Exit with the code
  7. Test error paths
  8. Common pitfalls
  9. Conclusion

Introduction

The hardest part of typed CLI errors is not writing the code — it is deciding what the code is allowed to reject. A error handling setup that accepts everything is easy to ship and expensive to operate, because every malformed input becomes a production surprise. The discipline is to enumerate the shapes the system actually receives, encode them in error handling, and let the library reject the rest loudly. This keeps the happy path fast and the failure path visible.

Most teams adopt error handling for typed CLI errors after an incident teaches them that unvalidated input is load-bearing. The incident is the expensive way to learn the lesson; a quarterly review of the input shapes is the cheap way. Pin the shapes with error handling, refuse the deploy that breaks the pin, and treat the pin as a contract the rest of the system can rely on. A contract that is not enforced is a wish.

The sections below walk through the concrete decisions: where to put the error handling check, how to keep it fast under load, what to do when the contract changes, and how to test the failure paths so they degrade on purpose instead of by accident.

Define error classes

Define error classes is the first decision because it determines what the rest of the error handling pipeline can assume. A wrong choice here turns into typed CLI errors under load, and the invariant you thought error handling guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Define error classes is the one most teams skip: drive error handling to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

class UsageError extends Error { code = 2; }
class RuntimeError extends Error { code = 1; }
try { await run(); } catch (e) {
  console.error(e.message); if (verbose) console.error(e.stack);
  process.exit(e.code ?? 1);
}

The javascript above is the shape to ship for typed CLI errors. It is small enough to audit and specific enough that the failure behavior is obvious. Adapt the constants to your traffic and latency budget, and resist the urge to handle cases the system has not seen yet.

Map to exit codes

The mechanism behind Map to exit codes is straightforward in error handling, but the consequences ripple. A wrong choice here turns into typed CLI errors under load, and the invariant you thought error handling guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Map to exit codes is the one most teams skip: drive error handling to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

Print user-friendly msg is where most error handling implementations leak performance. A wrong choice here turns into typed CLI errors under load, and the invariant you thought error handling guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Print user-friendly msg is the one most teams skip: drive error handling to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

test('usage error exits 2', () => {
  expect(() => run()).toThrow(UsageError);
});

Pin the behavior with this test. A error handling setup that passes this test degrades on purpose instead of by accident, which is the difference between a managed failure and an incident.

Log stack in verbose

The hard part of Log stack in verbose is not the happy path — it is what happens when error handling fails halfway. A wrong choice here turns into typed CLI errors under load, and the invariant you thought error handling guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Log stack in verbose is the one most teams skip: drive error handling to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

Exit with the code

Exit with the code should be observable without a debugger. A wrong choice here turns into typed CLI errors under load, and the invariant you thought error handling guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Exit with the code is the one most teams skip: drive error handling to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

Test error paths

Test error paths is a trade, not a winner. A wrong choice here turns into typed CLI errors under load, and the invariant you thought error handling guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Test error paths is the one most teams skip: drive error handling to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

Common pitfalls

The common pitfalls in typed CLI errors all reduce to one mistake: treating error handling as a formality instead of a contract. The contract is the thing the rest of the system relies on, and a weak contract propagates weakness everywhere.

  • Skipping the error handling boundary so typed CLI errors enters unvalidated.
  • Assuming error handling is fast without measuring the tail latency under load.
  • Letting typed CLI errors fail silently instead of failing loudly with a metric.
  • Testing only the happy path so the error handling failure surfaces in production.
  • Sharing mutable state across requests so one bad input poisons the next.

Conclusion

Do not conflate the conclusion with the implementation. They look similar from the outside but the failure modes differ: error handling fails fast and the caller knows, while an unchecked path fails slow and the caller guesses. Choose the one whose failure mode your system can absorb, and document the choice at the call site.

Self-check: write a test that drives error handling to the failing condition and assert the system degrades gracefully. Then typed CLI errors is a property you maintain, not a feature you hope for.

Sources: Node exit codes, Error handling

FAQ

Stack trace?
Only in verbose. The user wants the message, not the trace.
Sentry?
Optional. Respect a --no-telemetry flag.

Related Examples