Databases and Data Validation

Data Validation and Zod


Learning Objectives

  • You know of data validation and you know of locations for data validation.
  • You know of Zod and can implement basic data validation with Zod.

At this point, we’ve learned to create applications that interact with a server. We have not, however, been concerned with the correctness or validity of the data. For example, we have not cared whether the data sent to the server contains unwanted contents.

There is a need for data validation, as it helps with limiting processing of data to only valid data and helps to avoid errors. As an example, if the server expects a number in the input, one needs to verify that the data sent to the server is a number, or the server might crash or otherwise behave unexpectedly when processing invalid data.

Data validation

Data validation refers to the process of verifying that given data is in an expected format. This includes:

  1. Verifying the data type. This includes assessing that the data is in correct format. As an example, numeric fields should only accept numeric data.
  2. Verifying that the data has only acceptable values. As an example, when entering emails, the validation functionality should check that the entered email is formatted like an email address. As another example, if the input data relates to an enumerable value such as a country, the input data could be compared to all possible options to ensure that the entered country is a valid country.
  3. Checking for uniqueness if needed. As an example, if a system collects email addresses, the system should verify that each email should be entered only once to a list of emails.
Loading Exercise...

When developing applications, there are multiple locations for data validation.

  • Client-side data validation focuses on verifying that the users type in data in a specific format. This is done with specific input fields as well as with client-side JavaScript. Client-side validation is done for usability — anyone who knows a bit about how the web works can bypass client-side data validation.

  • Server-side data validation focuses on verifying that the data sent to the server follows specific rules. In practice, this functionality is added to the functions that handle incoming requests. Server-side data validation may also include verifying that data sent from the server to the user is valid.

  • Data validation in the database refers to verifying that the data stored in the database follows the correct format. With relational databases, which we look in the next part, some of this can be automated — for example, using a column that accepts only numeric data can be used to restrict inputs. Relational databases also feature constraints (e.g. NOT NULL, UNIQUE, CHECK, PRIMARY KEY, FOREIGN KEY), which can be further used to restrict what can be added to the database, and also, to verify that the data follows specific rules (e.g. a an account can be only added to an existing user).

Loading Exercise...

Zod validation library

Data validation is typically done with the help of validation libraries, which provide functionality for defining validation rules and for validating data against these rules. One such library is Zod.

Zod is is based on the idea of pre-defining the expected data format, i.e. “the schema”, and checking whether data follows the format. To use Zod, add it to the deno.json of your project, as follows. The following deno.json has also imports for Hono and Postgre.js.

{
  "imports": {
    "@hono/hono": "jsr:@hono/hono@4.6.5",
    "postgres": "https://deno.land/x/postgresjs@v3.4.4/mod.js",
    "zod": "https://deno.land/x/zod@v3.23.8/mod.ts"
  }
}

Validating an email

The following example outlines a program that imports Zod, creates a validation rule for an email, and then validates two strings. The first string is not an email, while the second string is an email.

import { z } from "zod";

const validator = z.string().email();

let result = validator.safeParse("This is not an email");
console.log(result);

result = validator.safeParse("this-is-an@email.com");
console.log(result);

Below, the program has been saved to a file called zod-test.js, which is then run using deno run zod-test.js.

deno run zod-test.js
{ success: false, error: [Getter] }
{ success: true, data: "this-is-an@email.com" }

As we see from the above output, the first validation fails, while the second validation succeeds. The success (or failure) of the validation is stored in the success attribute of the validation result. In addition, in the case the validation succeeds, the validated data is stored in the data attribute of the validation result.

Validation primitives

Zod comes with a variety of validation primitives that can be used to validate data, ranging from strings to numbers and booleans. For some of these, like strings, there are additional specific rules, such as the email validation shown above.

As an example, the following program shows simply how to validate whether an input is a string.

import { z } from "zod";

const validator = z.string();

let result = validator.safeParse("a string");
console.log(result);

result = validator.safeParse(123);
console.log(result);

The primitives are highlighted in the documentation of Zod at https://zod.dev/?id=primitives.

Loading Exercise...

In the case of web applications, the data sent to a server is often parsed as an object, and hence functionality for validating objects would be meaningful. Zod provides functionality for validating objects as well.

Validating objects

In the following, we create a validator that validates an object. The object must contain an attribute email that needs to be an email address.

import { z } from "zod";

const validator = z.object({
  email: z.string().email(),
});

let result = validator.safeParse("this-is-an@email.com");
console.log(result);

result = validator.safeParse({ email: "this-is-an@email.com" });
console.log(result);
deno run zod-test.js
{ success: false, error: [Getter] }
{ success: true, data: { email: "this-is-an@email.com" } }

Object validation functionality is highlighted in Zod’s documentation at https://zod.dev/?id=objects.

Loading Exercise...

Coercion

At times, data sent to the server can be in incorrect form. Coercion refers to the process of converting data from one type to another. In the case of validation, coercion is used to convert data from one type to another before the validation is performed. As an example, if we would be validating a year of birth, we might want to convert the year of birth to a number before validation.

Coercion is especially relevant when working with forms, as form data is by default submitted as strings.

Zod supports coercion out of the box with coerce, which is an attribute of z, which is followed by the primitive type into which the data should be coerced into. As an example, the following code coerces the year of birth to a number.

const validator = z.object({
  email: z.string().email(),
  yearOfBirth: z.coerce.number().min(1900).max(2030),
});
Loading Exercise...

Retaining only relevant data

Zod comes with a feature where the validation retains only the relevant data. This is illustrated in the following example, where the object that we want to validate has an attribute garbage in addition to the attribute email. When we run the validation, the object in the data attribute of the validation result contains only the email.

import { z } from "zod";

const validator = z.object({
  email: z.string().email(),
});

let result = validator.safeParse({
  garbage: "not needed",
  email: "another@email.com",
});

console.log(result);
deno run zod-test.js
{ success: true, data: { email: "another@email.com" } }
Loading Exercise...

Custom validation error messages

It is also possible to define custom validation error messages. They are entered into the validation primitives as objects that contain an attribute message. The following shows an example of providing custom validation error messages to the application.

const validator = z.object({
  email: z.string().email({ message: "The email was not a valid email." }),
  yearOfBirth: z.coerce.number({
    message: "The year of birth was not a number.",
  })
    .min(1900, { message: "The year of birth cannot be smaller than 1900." })
    .max(2030, { message: "The year of birth cannot be larger than 2030." }),
});

Zod Middleware for Hono

Hono has a middleware for Zod that can be used to add validation functionality to routes. To take the middleware into use, we add the middleware to the application dependencies in deno.json, as shown below.

{
  "imports": {
    "@hono/hono": "jsr:@hono/hono@4.6.5",
    "postgres": "https://deno.land/x/postgresjs@v3.4.4/mod.js",
    "zod": "https://deno.land/x/zod@v3.23.8/mod.ts",
    "zValidator": "npm:@hono/zod-validator"
  }
}

And, now, we can import the validator into the application and use it to validate data.

import { zValidator } from "zValidator";

The zValidator is a function that is given two arguments: the type of the data to be validated and the Zod validator to use when validating the data. When used with Hono, the value returned from calling zValidator is passed to a route, where it is used to validate the data in the request.

The example below shows a full application that can be used to validate emails using Hono, Zod, and Hono middleware for Zod. The application uses the zValidator middleware to validate JSON data in the request body, ensuring that the JSON document in the request body has a valid email address.

import { Hono } from "jsr:@hono/hono@4.6.5";
import { cors } from "jsr:@hono/hono@4.6.5/cors";
import { z } from "zod";
import { zValidator } from "zValidator";

const app = new Hono();
app.use("/*", cors());

const emailValidator = z.object({
  email: z.string().email(),
});

app.post("/emails", zValidator("json", emailValidator), (c) => {
  const data = c.req.valid("json");
  return c.json(data);
});

Deno.serve(app.fetch);

When using the middleware, the “Content-Type” header with the correct value must be added to the request. As an example, if JSON data is sent to the server, the value of the “Content-Type” header must be application/json. If the header is not present, the middleware will automatically respond with a validation error.

In the example below, we send a valid email with the content type set to application/json. The response from the server contains the email in the response body, and the status code indicates that the request was successful.

curl -v -X POST -H "Content-Type: application/json" -d '{"email":"valid@email.com"}' localhost:8000/emails
// ...
< HTTP/1.1 200 OK
// ...
{"email":"valid@email.com"}%

In the next example, below, the request is made with the correct data but without the “Content-Type” header.The response status is 400, indicating a bad request. In addition, the response shows that the request is not successful, and the error indicates an “invalid_type”.

curl -v -X POST -d '{"email":"valid@email.com"}' localhost:8000/emails
// ...
< HTTP/1.1 400 Bad Request
< access-control-allow-origin: *
< content-type: application/json; charset=UTF-8
< vary: Accept-Encoding
< content-length: 161
< date: Mon, 11 Nov 2024 12:40:58 GMT
<
* Connection #0 to host localhost left intact
{"success":false,"error":{"issues":[{"code":"invalid_type","expected":"string","received":"undefined","path":["email"],"message":"Required"}],"name":"ZodError"}}%

When we make a request to the server with invalid data but with the correct “Content-Type” header, the status code of the response is again 400. This time, the issue related to the email is present in the response, where the message has the string “Invalid email”.

url -v -X POST -H "Content-Type: application/json" -d '{"email":"invalid"}' localhost:8000/emails
// ...
< HTTP/1.1 400 Bad Request
// ...
{"success":false,"error":{"issues":[{"validation":"email","code":"invalid_string","message":"Invalid email","path":["email"]}],"name":"ZodError"}}%
Loading Exercise...

Client-side validation

On the client, we can rely on API responses for providing information on whether the data submitted to the server is valid or not. As an example, the following outlines a component that uses the above API endpoint for validating an email from the user.

<script>
  import { PUBLIC_API_URL } from "$env/static/public";
  let errors = $state([]);

  const submitForm = async (e) => {
    errors = [];
    e.preventDefault();

    const form = Object.fromEntries(new FormData(e.target));
    const response = await fetch(`${PUBLIC_API_URL}/emails`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify(form),
    });

    const data = await response.json();
    if (data.success === false) {
      errors = data.error.issues;
      return;
    }

    // logic for successful response
  };
</script>

<form onsubmit={submitForm}>
  <label for="email">Type in your email</label>
  <input type="email" name="email" id="email" /><br/>
  <input type="submit" value="Send email to server" />
</form>

{#each errors as error}
  <p>Error: {error.message}</p>
{/each}

In the above example, the user can input an email address and when the form is submitted, the email is sent to the server. If the email is invalid, the error message is shown to the user. Note that the form is submitted with the application/json content type, as the server expects JSON data.

It would be also possible to parse the errors, and show them individually to the user next to the specific input elements. This could provide more targeted information on what went wrong.

There are also client-side libraries that can be used for data validation. For example, Zod can could added as a dependency to our client-side project. There are also libraries for specific tasks such as the Superforms library for forms.

Regardless of how the validation is done, it is important to keep the role of the client and the server in mind. Client-side validation is used to provide feedback to the user, while server-side validation is used to ensure that the data is in the correct format and that it can be used safely.