Error Messages Are User Interface
When someone hits an error in your software, they're already having a bad time. They tried to do something. It didn't work. They are now looking at your error message for help.
This is a moment of trust. What you say next matters.
The anatomy of a bad error message
Error: ECONNREFUSED
I see messages like this in production systems all the time. This tells the developer that a connection was refused. It does not tell them:
- Which connection? To what service?
- Was it the database? The cache? An external API?
- Is this a configuration problem or a runtime failure?
- What should they try first?
The error is technically accurate and practically useless.
What a good error message looks like
A good error message answers three questions:
- What happened? Describe the failure in plain language.
- Why did it happen? Give enough context to understand the cause.
- What should I do? Suggest a next step.
// Before
throw new Error("Connection refused");
// After
throw new Error(
`Failed to connect to PostgreSQL at ${host}:${port}. ` +
`Connection refused after ${retries} attempts. ` +
`Check that the database is running and the ` +
`DATABASE_URL environment variable is correct.`
);The second version is longer. It's also the difference between a developer fixing the problem in thirty seconds and a developer spending twenty minutes grepping through configuration files.
Error messages for humans
If your software has end users (not just developers), the stakes are higher. A regular person seeing FATAL: password authentication failed for user "postgres" is not going to understand what that means. They shouldn't have to.
User-facing error messages should:
- Use language the person understands
- Never expose internal implementation details
- Suggest a concrete action ("Try signing in again" rather than "Authentication failed")
- Provide a way to get help if the action doesn't work
Errors as documentation
I've started thinking of error messages as a form of documentation. They're the documentation that shows up exactly when you need it, in exactly the context where it's relevant.
When I write a validation function, I spend almost as much time on the error messages as on the validation logic itself. Because the validation logic runs once and either passes or fails. The error message is what someone reads when it fails, and it needs to teach them what "correct" looks like.
function validateConfig(config: Config): void {
if (!config.apiKey) {
throw new ConfigError(
`Missing required field "apiKey". ` +
`Get your API key from https://dashboard.example.com/keys ` +
`and set it in your configuration file.`
);
}
if (config.timeout && config.timeout < 0) {
throw new ConfigError(
`"timeout" must be a positive number (in milliseconds), ` +
`but received ${config.timeout}. ` +
`Use 0 for no timeout, or omit the field to use the default (30000ms).`
);
}
}Every error message here is a tiny lesson. That's the point.
The investment pays off
Writing good error messages takes more time than writing bad ones. But the time investment is asymmetric: you write the message once, and it saves time for every person who encounters that error, for as long as the software exists.
It's one of the highest-leverage things you can do as an engineer. And it requires nothing more than empathy and a few extra minutes.