Try   HackMD

Vue 3 - TS-REST & ZOD (EN)

Understanding the Benefits of API Field Validation Contracts.

When developing frontend applications in collaboration with backend teams, we often encounter a common issue:

API field types or field names are incorrectly specified, and the backend insists that it's a frontend issue. Alternatively, the frontend payload might be incorrect, but the frontend believes the backend secretly changed the API.

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

In such cases, it can lead to spending a lot of time tracing the root cause of the problem, debugging, and communicating. However, these issues can be effectively avoided and resolved through the use of API field validation contracts.

We will introduce how to use the ts-rest and zod libraries in a TypeScript environment to create a robust API field validation contract, ensuring consistency in data transmission between the frontend and backend. This approach not only reduces friction during the development process but also enhances the overall stability and maintainability of the project. It’s especially beneficial when the frontend and backend need to develop in parallel according to a predefined schema under tight deadlines.

ts-rest

https://ts-rest.com/
A tool for defining and typing APIs that allows the frontend and backend to share the same API definition contract, enabling developers to catch type inconsistencies in API fields at compile time.

zod

https://zod.dev/
A powerful type-checking and data validation tool that can be used to define and validate object data structures, seamlessly integrating with ts-rest.

By leveraging these two tools, we can establish a clear and reliable API auto-validation process, ensuring data integrity and correctness. The following example demonstrates how to implement these features in a Vue 3 project.

Let's install the packages:
npm install @ts-rest/core zod

Designing a Basic API Contract

Assume our API includes an OTP password GET request required during login and a POST request for the product list.

We will name them OtpResponseSchema and GoodsListResponseSchema, respectively.

// src/service/api.ts import { request } from '@/service/request' // axios request instance import { initContract } from '@ts-rest/core' // ts-rest initialization method import type { AppRoute, ClientInferRequest } from '@ts-rest/core' // Request route and other type definitions import { z } from 'zod' // Main methods from ZOD /** Define the response data structure for two APIs */ const OtpResponseSchema = z.object({ Code: z.number(), Msg: z.string(), Data: z.object({ OTP: z.string() }) }) // The reason for the initial capital letters is simply because // my API is designed this way. Modify it according to your own project needs. const GoodsListResponseSchema = z.object({ Code: z.number(), Msg: z.string(), Data: z.array( z.object({ Show: z.boolean(), Price: z.number(), CreatedAt: z.number(), DeletedAt: z.number().nullable(), Description: z.string(), GoodsSpecs: z.array( z.object({ ID: z.number(), GoodsID: z.number(), Specs: z.string(), CreatedAt: z.number() }) ), ImageUrls: z.array( z.object({ ID: z.number(), Name: z.string(), Alias: z.string(), DeletedAt: z.number().nullable(), CreatedAt: z.number() }) ) }) ) }) /** API Contract for validation */ // The `satisfies` operator is a new feature introduced in TypeScript 4.9. // Its main purpose is to perform type checks while retaining the precision of literal types. const getOtpResponse = { method: 'GET', path: '/admin/otp', responses: { 200: OtpResponseSchema }, summary: 'Login' } satisfies AppRoute const getGoodsListResponse = { method: 'POST', path: '/member/goods/list', responses: { 200: GoodsListResponseSchema }, summary: 'Get goods list', body: z.object({ page: z.number(), pageSize: z.number() }) } satisfies AppRoute export const apiContract = initContract().router( { getOtpResponse, getGoodsListResponse }, { pathPrefix: '/api' } ) // Generate interface, which can be directly used for type checking in components export interface ApiContract extends ClientInferRequest<typeof apiContract> {}

src/views/Home.vue (or any other component that needs to request APIs)

// src/views/Home.vue // script setup import { ref, onMounted, nextTick } from 'vue' import { getOtp, getGoodsList } from '@/service/api' import { apiContract } from '@/service/api' // Directly use the checker we defined in Zod as an interface type GoodsItem = z.infer<typeof GoodsListResponseSchema>['Data'][number] const goodsList = ref<GoodsItem[]>([]) const getOtpNumbers = async () => { try { const res = await getOtp({ method: apiContract.getOtpResponse.method, url: apiContract.getOtpResponse.path }) const validation = apiContract.getOtpResponse.responses[200] if (res.data.Code === 200 && validation.parse(res.data)) { state.value.currentOtp = res.data.Data.OTP } } catch (error: any) { throw new Error(error.toString()) } } const fetchGoodsList = async (page: number, pageLimit: number) => { try { const res = await getGoodsList({ method: apiContract.getGoodsListResponse.method, url: apiContract.getGoodsListResponse.path, data: { Page: page, PageLimit: pageLimit } }) const validation = apiContract.getGoodsListResponse.responses[200] if (res.data.Code === 200 && validation.parse(res.data)) { goodsList.value = res.data.Data } } catch (error: any) { // Handle error console.error('Failed to fetch goods list:', error) throw new Error(error.toString()) } } onMounted(async () => { await nextTick() await getOtpNumbers() await fetchGoodsList(1, 20) }) </script>

If Zod detects any type mismatches during the API request, you will see this error in the console:

image

This means that the OTP field was expected to be a number, but the returned value was a string.

At this point, you can check the API documentation to see who made the mistake with the field type… 😃😃😃

Conclusion

When the development timeline feels tighter than your favorite pair of jeans after a big meal 🍕, having a reliable API contract can be a lifesaver.

By treating the API documentation as the ultimate truth, both frontend and backend teams can work in harmony —even when those inevitable UI challenges or backend quirks pop up. 🍻🍻🍻

While we can't promise this will eliminate all the hiccups (because, let's face it, what’s development without a little drama? 😅), it sure does help in catching those pesky data format issues early on.

This not only cuts down on endless back-and-forths and debugging marathons 🏃‍♂️, but also boosts overall development efficiency. In the end, your application will be as robust as a superhero in a blockbuster movie.
🚀🚀🚀

tags: VueJS Vue3 Typescript API Validation ts-rest zod Frontend Development Backend Integration Web Development