The post is divided into two parts. If you want to read part 1 first, please go to here.

As part 1 is about runtime validation. Part 2 will focus on use cases. I will use Nuxt3 as example.

Here I have 3 use cases: page, route guard (Vue route) and API.

Prerequisite

  • Basic knowledge: JavaScript, TypeScript, Nuxt3, Vue3, zod

Case 1: Page Validation

Suppose we have a post list page with route: /posts?page=3&sort=desc&limit=20.

Then let’s define the route parameter model:

enum Sort {
  ASC = 'asc',
  DESC = 'desc'
}

// PS. RP means route parameter, which is my naming preference.
interface RPPostList {
  limit?: number;
  page?: number;
  sort?: Sort;
}

And validator:

const RPPostListValidator: z.ZodSchema<RPPostList> = z.object({
  limit: z.coerce.number().int().positive().max(50).optional(),
  page: z.coerce.number().int().positive().optional(),
  sort: z.nativeEnum(Sort).optional()
})

The data we plan to validate is from url. That means we will only get value with string type. So the values from page and limit should be transformed into number before validate it. Otherwise, we will get "3" instead of 3 from page. Thankfully, the latest version of zod has coerce method, we can transform it easily.

  • page: page means current page number, so it should be positive integer.
  • limit: limit means how many items per page, which is also a positive integer. Besides, it is limited to 50 as maximum value due to performance concern. Of course we can define it in the backend.
  • sort: sort is limited to desc or asc.

In the page component, here is the process:

  1. We get query string from url with route.query in Nuxt3.
  2. Validate the query string.
  3. After safely parsed the data, we can do whatever we want.

Here is an example:

<script setup lang="ts">
// directory: src/pages/posts.vue
import { SafeParseReturnType } from 'zod';

const route = useRoute();

const queryStringValidation = computed<SafeParseReturnType<RPPostList, RPPostList>>(() =>
  RPPostListValidator.safeParse(route.query),
);

const currentPage = computed<number>(() => {
  if (queryStringValidation.value.success) {
    // if query string has page, return page, otherwise, return 1
    return queryStringValidation.value.data?.page ? queryStringValidation.value.data.page : 1;
  } else {
    // if validation failed, return 1 whatsoever
    return 1;
  }
});

const currentSortType = computed<Sort>(() => {
  if (queryStringValidation.value.success) {
    return queryStringValidation.value.data?.sort ? queryStringValidation.value.data.sort : SortingEnum.DESC;
  } else {
    // if validation failed, return desc as default sorting
    return Sort.DESC;
  }
});
</script>

For currentPage, if query string didn’t have page value, or giving something strange page like ?page=-1, ?page=hello, then we will give page number 1 as default. Same as currentSortType.

Case 2: Validation in Route Guard

Consider we have a post page with route: /post/:id.

The route middleware in Nuxt3 will triggered before entering the page. So it is very suitable to make a route guard using route middleware.

// directory: /src/middleware/postGuard.ts
import { z } from 'zod';

// PS. RP means route param, which is my naming preference.
interface RPPost {
  id: number;
}

const RPPostValidator: z.ZodSchema<RPPost> = z.object({
  id: z.coerce.number().int().positive(),
});

export default defineNuxtRouteMiddleware((to, _from) => {
  const isValid: boolean = RPPostValidator.safeParse(to.params);

  // if validation failed, redirect to home page
  if (!isValid) {
    return navigateTo('/');
  }
});

As the validator shown above, id is considered as post id. The post id in our database will be PK, or primary key. And primary keys are positive integers. So it is unnecessary to go to page like: /post/-123, /post/2.35 or /post/hello. Without asking the database, we have confidence to say that there’s no such a post in our database. So we can directly redirect it to home page or error page.

Incoming url might be various and unpredictable. (Or should I say untrustable? Sounds like a skeptic, LOL.) So it is safer to do strict check before we use it.

Speaking of untrustable, it remind me of an quote from a great assassin Altair, once he said:

Nothing is true, everything is permitted.

Altair

Here we can say:

Nothing is true, everything should be validated. 🤘

Case 3: API Validation

When encounter an API, I asked myself:

  • Which data is unsafe and needs validation?
  • When each validation failed, how to handle the error?

Until now, the process below is what I think as a good practice:

  1. Validate incoming payload: from route params & query string
  2. If failed, throw 400 bad request
  3. Query data, DB connection
  4. Validate raw data
  5. If failed, throw 500 internal server error
  6. Return data as response

Here’s an Nuxt3 server API for querying single post data. The endpoint is /api/post/:id using GET http method. To demo, I gathered all models and validators together for easy reading. For real world use, they will be placed in other directory respectively.

// directory: /src/server/api/post/[id].get.ts
import { z } from 'zod';

interface RPPost {
  id: number;
}

const RPPostValidator: z.ZodSchema<RPPost> = z.object({
  id: z.coerce.number().int().positive(),
});

// PS. M means model, which is my naming preference.
interface MPost {
  id: number;
  content: string;
  publishAt: Date;
  title: string;
}

const MPostValidator: z.ZodSchema<MPost> = z.object({
  id: z.number().int().positive(),
  content: z.string(),
  publishAt: z.date(),
  title: z.string().min(1),
});

// Nuxt3 server API
export default defineEventHandler(async (event) => {
  // Step 1 - get payload from router, query string or request body
  const params = getRouterParams(event);

  // Step 2 - payload validation
  const routerParamValidation = RouterParamValidator.safeParse(params);

  // Step 3 - if validation failed, would throw 400 bad request
  if (!routerParamValidation.success) {
    throw createError({ message: 'Request is invalid.', statusCode: 400 });
  }

  // Step 4 - db connection query method defined at another place
  const rawData = await getPostById(routerParamValidation.data.id);

  // Step 5 - raw data validation
  const rawDataValidation = MPostValidator.nullable().safeParse(rawData);

  // Step 6 - if validation failed, would throw 500 internal error
  if (!rawDataValidation.success) {
    throw createError({ message: 'Data and model mismatched.', statusCode: 500 });
  }

  // Step 7 - everything is fine, then transform data into view model, then return it
  return rawDataValidation.data;
});

An API also get url like route middleware does. So they might share the same route param model and validator. Besides, if accepted request body like POST API, it should also validate request body, too.

If route params was illegal, throw 400 bad request error to user. And if it passed the validation, then continue to the next step: connect with external source (like database, APIs) to get data.

Since we may get data from several external source listed below:

  1. From our database directly.
  2. From APIs created by our backend colleagues.
  3. From third party APIs.
  4. …any other external sources you might need.

There are some concerns about the data from each source:

  1. Database: database might stored dirty data under development or other reasons.
  2. APIs from backend: Your backend colleagues updated their data model or adjusted their APIs, but forgot to inform you. Human makes mistakes.
  3. Third party APIs: Third party APIs might change their response, and of course the provider is not obligated to notify everyone who use it, especially free APIs.

So it is much safer to validate the raw data before using it.

If validation failed, throw 500 internal server error error to user if necessary. Maybe throw a 500 error might be too radical for someone. If so, another choice is to just log error down without throwing an error.

But what I concerned here is, any kind of dirty data (no matter how small it is) might have risks to break front end page. A frontend developer might murmur like this: The page was good yesterday, why is it broken today? I didn’t touch anything… WHY?!

So, the stricter the better.

After all validations passed, we are good to go: returning back as a response.

Conclusion

Validation process might be tedious, but it is worthy in the long term. Maybe it is frustrated. But it is more frustrated when we get a bomb (dirty data) that crash our page at any unexpected time… (Saying when we are going to sleep.)

validation everywhere

Since I played Minesweeper and got GAME OVERS some many times when developing, it is time to face the music. Then it leads me to the ideas mentioned above. Hope it is helpful!

Happy coding.

Reference