How to Build a CLI Config File
Load a CLI config from a file with layered precedence so flags override env, env overrides file, and file overrides defaults.
On this page
Introduction
The hardest part of resolve config precedence is not writing the code — it is deciding what the code is allowed to reject. A config layering 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 config layering, and let the library reject the rest loudly. This keeps the happy path fast and the failure path visible.
Choosing config layering for resolve config precedence is a trade, not a winner. You give up some flexibility to get a hard boundary, and the exchange pays off when the boundary catches a class of bugs that would otherwise surface at 3 a.m. Document the trade at the call site so the next reader does not undo the guard thinking it is a bug. The reader who does not know why the guard exists is the reader who removes it.
The sections below walk through the concrete decisions: where to put the config layering 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 defaults
Define defaults is the first decision because it determines what the rest of the config layering pipeline can assume. A wrong choice here turns into resolve config precedence under load, and the invariant you thought config layering 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 defaults is the one most teams skip: drive config layering 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.
const cfg = { ...defaults, ...fileConfig, ...envConfig, ...flagConfig };
The javascript above is the shape to ship for resolve config precedence. 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.
Load the file
The mechanism behind Load the file is straightforward in config layering, but the consequences ripple. A wrong choice here turns into resolve config precedence under load, and the invariant you thought config layering 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 Load the file is the one most teams skip: drive config layering 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.
Read env vars
Read env vars is where most config layering implementations leak performance. A wrong choice here turns into resolve config precedence under load, and the invariant you thought config layering 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 Read env vars is the one most teams skip: drive config layering 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('flags override env', () => {
expect(cfg.port).toBe(flagPort);
});
Pin the behavior with this test. A config layering setup that passes this test degrades on purpose instead of by accident, which is the difference between a managed failure and an incident.
Parse flags
The hard part of Parse flags is not the happy path — it is what happens when config layering fails halfway. A wrong choice here turns into resolve config precedence under load, and the invariant you thought config layering 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 Parse flags is the one most teams skip: drive config layering 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.
Merge in order
Merge in order should be observable without a debugger. A wrong choice here turns into resolve config precedence under load, and the invariant you thought config layering 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 Merge in order is the one most teams skip: drive config layering 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 precedence
Test precedence is a trade, not a winner. A wrong choice here turns into resolve config precedence under load, and the invariant you thought config layering 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 precedence is the one most teams skip: drive config layering 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 resolve config precedence all reduce to one mistake: treating config layering 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 config layering boundary so resolve config precedence enters unvalidated.
- Assuming config layering is fast without measuring the tail latency under load.
- Letting resolve config precedence fail silently instead of failing loudly with a metric.
- Testing only the happy path so the config layering 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: config layering 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 config layering to the failing condition and assert the system degrades gracefully. Then resolve config precedence is a property you maintain, not a feature you hope for.
Sources: cosmiconfig, 12-factor
FAQ
- What format?
- JSON or YAML. TOML if you want comments. Let the user choose.
- Config validation?
- Validate with a schema after merge. Fail fast on bad config.
Report a problem with this articleOpens a pre-filled issue on GitHub.