Data Validation and Error Handling Best Practices
The goal of this article is to expand on some features of data validation and error handling that we have found helpful at Echobind. It’s geared toward applications and APIs that are ultimately consumed by casual end users (i.e. people who aren’t developers). This isn’t an exhaustive list.
Validate data and handle errors from the beginning
The simplest time to implement validation and error handling is at the beginning of a project. Plan how you want it to work before starting your project, and then devote some extra time to the first few features of the app to really get validation and error handling dialed in. These features will probably be copied/pasted into other features in the app down the road, so it pays to get them working the way you want early on.
Helpful messaging
The users of your application are humans. Your messaging should reflect that. Error messages should:
- Use complete sentences.
- Explain what went wrong.
- Give the user a next step.
Use complete sentences
This example is common enough. You fill out a form, hit submit, and get some one-word error messages. Instead, use complete sentences:
Explain what went wrong
If a user encounters an error state in your application, it isn’t enough for them to know that something went wrong. Without information about what the problem is, they won’t know what they can do to resolve it. Was the issue caused by something they did? Is it a problem with the application? Will it be resolved if they come back later?
To clarify, the goal here isn’t to give the user a technical understanding of the inner workings of your application. It’s to help them understand the problem so they can take appropriate action.
It isn’t always possible to tell the user what went wrong, but try to give them something.
Starting with an example of an error message that doesn’t include an explanation:
So what went wrong? Is there an issue with the form values? Did I lose my internet connection? Is the API server down? Will this issue be resolved if I try again? Maybe if I try again in an hour?
Some better examples might be:
This tells the user the problem is with the data they provided in the form. They can look at individual form errors to fix them.
This tells the user the issue is likely with their internet connection. Once their internet is working, they can try again.
This tells the user that the issue is with the app itself. They can try again, but if the problem keeps happening, there’s probably nothing they can do to fix it. Notice that we’re also telling the user that the thing they’re trying to do didn’t happen. They aren’t left wondering if the account was created or not.
Give the user a next step
Sometimes it isn’t intuitive to casual users what they can do to resolve an error. Giving the user a next step helps them understand how to complete the action they’re trying to perform. Explain how to resolve the error, ask the user to try again, direct them to some documentation, provide a link to a contact form to report an issue, etc.
Sometimes this step is implied in the explanation of what went wrong and doesn’t need to be explicitly stated (e.g. “Your password must be at least 6 characters long.”—the next step is clear).
Let’s use the examples from the previous section to illustrate this:
This is helpful, the user knows the problem is with the form values. But what’s wrong with the form values? How can the user find out what they need to fix?
This tells the user that there’s more information in the form itself. They can look at individual field errors to find out what to fix.
This isn’t always necessary. If the form field errors are always visible on the same screen as this higher-level error message, it will likely be clear to the user what needs to be fixed. However, if the form is long enough, the user might need to scroll down to see the field errors. In this case, it’s helpful to tell them to look for field errors in the form.
Another example:
This tells the user that the issue is likely caused by their internet connection and that restoring that connection will solve the problem. If this issue could also be caused by the remote server being down, it would be good to include the next steps for that as well:
Last example:
In this case, we might not know exactly what went wrong. But we can still give the user some options. Maybe it’s a fluke and trying again will resolve it. If not, they can contact the support team (with a link to that page) as a last resort.
Human messaging
Never expose implementation details in your error messages. Your consumers don’t need to know that there was a foreign key constraint issue in your database query. This means you should likely not be sending some library’s exception message along to your consumers.
You’ve probably seen error messages like this before. It probably came directly from an exception on the server. Maybe from code like this:
app.post('/user', (req, res) => { try { // add the user to the database } catch (err) { res.status(500).send(err.message) } })
There are two issues with messages like this:
- They aren’t helpful to the user.
- It exposes the implementation details of your application. This can pose security risks. If someone sees the error message above, they get a lot of information about how your app works: it uses Postgres, there are two tables named “Table3” and “Table1”, and there’s a foreign key named “Table3_DataID_fkey”.
As a general rule, you should never display an exception message to your users. They rarely contain the information you want your users to see.
Instead, in this case, it’s probably best to just return a more helpful error message from the server. Another option is to catch the error on the client side, ignore the message from the server, and pass your own message along. This isn’t ideal since server error messages can be much more specific about the problem (e.g. you could potentially tell the user that the database is down if that’s helpful to them). With client-side messages, they will likely have to be very general (unless you use error codes in your messages, which we’ll explore later).
Server-side error messages that are directed toward the end user are also helpful because you don’t need to include a lot of error message management on your client side. Just trust the server to only provide messaging that you want the user to see.
app.post('/user', (req, res) => { try { // add the user to the database } catch (err) { res .status(500) .send(`Something went wrong on our end and this account could not be created.`) } })
API messaging
Since most of the APIs we build at Echobind are essentially consumed by casual users (through a client-side app), it makes sense for us to format all of our API error messages in such a way that they can always be displayed directly to the user. So we apply all of the messaging best practices in this document to the error messages returned from the API.
This is beneficial because the API can return very specific error messages and we only need to manage messaging in one place (the API).
There are some occasions where we need to change the messaging on the client side (e.g. we want to add a link to “customer support”). Since we’re using error codes (details below), we can always target specific error messages to be overridden.
Error categories
Your API should make a clear distinction between an unexpected server error (e.g. your database is down) and invalid form data (e.g. a required field is missing). This will allow your front end to ask the user to take another look at their data or inform them that something unexpected happened.
For a REST API, this could be as simple as returning a 500 response status (e.g. couldn’t connect to the database) or a 400 status (e.g. invalid form data).
It should also be said that your client should be aware of this distinction and respond accordingly.
Error codes
Each of your API error messages should have a unique code. This allows the consumer to find out exactly what went wrong (match the error code) or do things like provide an alternative message, translate the message into another language, etc. E.g. {message: "Please provide a valid email address.", code: "EMAIL_INVALID"}
.
Error codes are a contract just like the rest of your API. You should not change or remove error codes without updating your API version.
For example, if a user is trying to create a new blog post in your app and there’s already a blog post with the same name, you might want to show the user a link to the original post. The server will return an error in this case, when the blog form is submitted. Without error codes, you won’t know that the error returned indicates that the blog already exists or if it’s some other error. In the worst case, you’d need to test the content of the message to see if it’s the one you’re looking for.
const response = await createBlogPost(data); if(!response.success) { if(response.errors.some(message => message.includes("already exists"))) { // fetch the original post and show it to the user } }
This is obviously a problem because the error message might change at some point. However, if your error messages have error codes, and you know those codes will never change, you could do something like this:
const response = await createBlogPost(data); if(!response.success) { if(response.errors.some(error => error.code === "BLOG_POST_TITLE_EXISTS")) { // fetch the original post and show it to the user } }
Or if you don’t like the error message that the server provides (it isn’t a complete sentence, doesn’t explain what went wrong, and doesn’t give a next step), you could provide your own:
const errorMessageMap = { "BLOG_POST_TITLE_EXISTS" => `A blog post with this title already exists. Please change the title so that it's unique.`, }
Client-side vs. server-side validation
Ideally, you’ll validate data on the client and on the server. You want to validate on the client because it creates a more responsive experience for the user. You want to validate on the server because you need to verify certain constraints before performing actions there.
That said, if you do validate on both the client and the server, you need to guarantee that you’re performing the same validation on both. You don’t want your client to throw an error on data that your server would accept, and vice versa.
You could do this by just copying/pasting the same validation code on your client and server. However, this will almost certainly lead to situations where you change a validator on one side but forget to make the same change on the other.
The better (and, we go a step further and say, only) way to do this is if you can share the code between your client and server. Some frameworks, like Next.js, allow this out of the box. But you could also do it using a monorepo, with a shared library between your client and server.
If this isn’t an option, then just do validation on the server. This will almost certainly be snappy enough for most apps. If validation feedback from the server isn’t as fast as it needs to be, then revisit how you’re doing validation at that point.
There will very likely be some validation tasks that cannot be done on the client (e.g. validation that requires database access). In those cases, share as much validation as you can between the client and the server, and then just perform those extra validation tasks on the server.
Targeted form messaging
If there are errors in your form fields, you want to display those error messages next to the field itself. You don’t want to do this:
Instead, you want to do this:
In order to do this, your error reporting will need to accommodate these kinds of form fields:
- Primitives. Normal form data (e.g. strings, numbers, dates, etc).
- Objects. You may also want to support fields that are a combination of other fields (e.g.
{name: {firstName: string, lastName: string}
). This is usually a bad idea, but since your top-level data is essentially an object, you might as well implement error reporting that supports it so that your nested error logic will work well with them. - Lists. Often you’ll want to allow users to add multiple entries for a field (e.g. multiple contact phone numbers). Each list item could be a primitive, an object (i.e. each entry will have its own fields), or a list. It’s a good idea to support infinite nesting in this way.
No matter how your forms are composed (e.g. primitives, objects, lists, nested objects/lists), you should be able to target an error message at any single field—even if it’s deeply nested in something like a list → object → list → primitive.
You’ll likely want to support multiple error messages per form field. Sometimes there are multiple things wrong with data and you’ll need to be able to report that to the user (e.g. “Your password must be at least 6 characters long” and “Your password must contain at least one capital letter.”). Therefore, all error message fields should be arrays (even if you only have one error message).
There are a few ways to do this. The simplest is to create an array of errors, each with a “path” to the field, a code, and a message:
{ errors: [ { code: "EMAIL_INVALID", path: ["reviewers", 3, "contact", "email"], message: "Please provide a valid email address.", } ] }
This is flexible enough to allow for multiple messages per field, and any level of field nesting. In the example above, it’s clear that the form has a list of reviewers
(probably allowing the user to add an arbitrary number of them), this is the 4th item in the list (index 3
), and each reviewer has a contact
field that has an email
sub-field.
Handle failed requests
Always handle failed requests. A request can fail for countless reasons: the server is down, the user’s internet connection is down, request data is invalid, there was an exception while processing the request, etc. Don’t just assume requests will always succeed. This includes fetching and mutating data. If a request fails, the user should be notified and given a next step, and your app itself should respond appropriately (e.g. don’t move along as though the request succeeded).
Handle loading states
Always handle loading states. This includes fetching and mutating data. If data is being fetched or mutated, that should be clear to the user. If data is being mutated, it’s likely you’ll want to make some restriction to the user’s interaction until the mutation completes (e.g. don’t allow the user to interact with or submit a form again).
Conclusion
There are certainly more items to add to this list and more to be said about each one. But the main takeaway is to be thoughtful and intentional about validation and error handling in our applications. It’s a frequently overlooked aspect of development, but the consequence is that it’s just as frequently something that makes us disappointed with the apps we use ourselves. We get stuck in weird states, we read unhelpful error messages, we watch loading spinners that never end, all because some application developers didn’t handle errors properly.
Validation and error handling can be something we continuously wrestle with and only address when the stars align and we’re lucky enough to encounter an error state during development. But with some upfront planning and time, it can be a part of our apps that “just works” and creates a great experience for our users.