- Paweł Jacewicz
- Read in 4 min.
Imagine you are a front-end developer. There is a new feature in your current project that needs to be implemented. You talk to the back-end team, agree on the API contract and start the implementation. You even write tests to make sure the front-end works in many different scenarios that can happen. And it is great, the feature is finished, everyone is happy.
Some time passes and then the feature stops working. You start to investigate what has just happened, but the front-end code seems to be fine. Even your tests do not fail. Then, you look at the Network tab in your browser’s DevTools. You see that the way you entered the API response is no longer valid. The contract has changed. And even worse, the changes propagate throughout the whole application. After fixing the issue, you begin to wonder, what can you do to avoid such problems in the future?
The solution – introduction to Zod
One of the solutions is to validate external data (e.g. API responses, localStorage) before using them in your application. That’s something we can achieve using Zod. Zod is a tiny library that allows the developer to define a schema and easily parse the data, either throwing an error or not, depending on the use-case. Let’s see some examples.
How to solve the issue step by step
There are many types of data that Zod supports, but let’s use something simple – parsing string values. First, we create a Zod type definition.
import { z } from 'zod'; const ZodString = z.string(); const ZodStringEmail = z.string().email(); const ZodStringLength = z.string().min(1).max(10);
Notice how we can narrow what we consider valid. Zod can check not only the type, but also whether value fits our needs (e.g. is an email address). Then we can use created objects to parse our data:
ZodString.parse(123); // throws a runtime error ZodString.parse('123'); // passes, we can use the return value safely ZodString.safeParse(123); // returns an object with success: false and error details ZodString.safeParse('123'); // returns an object with success: true and valid data
You can as well customize error messages:
const ZodStringCustomized = z.string({ invalid_type_error: 'Oh no!', });
Going back to our example with API contract, let’s say that before the changes, code responsible for an API call looked like that:
interface ApiResponse { foo: number; email: string; bar: ApiResponseBar[]; } interface ApiResponseBar { id: string; baz: string; } @Injectable({ providedIn: 'root' }) export class ExampleHttpService { private readonly http = inject(HttpClient); fetchData(): Observable<ApiResponse> { return this.http.get<ApiResponse>('/api/example') } }
Note: in this example I’m using Angular 14 and its new inject() function in order to get an instance of HttpClient. You could do that using the constructor as well.
With Zod, it would look a bit differently:
import { z } from 'zod'; const ZodApiResponse = z.object({ foo: z.number(), email: z.string().email(), bar: z.array( z.object({ baz: z.string(), id: z.string(), }) ), }); type ZodApiResponse = z.infer<typeof ZodApiResponse>; @Injectable({ providedIn: 'root' }) export class ZodHttpService { private readonly http = inject(HttpClient); fetchData(): Observable<ZodApiResponse> { return this.http .get('/api/example’) .pipe(map((response) => ZodApiResponse.parse(response))); } }
First we have created a Zod object type that represents our desired data structure. Then, using z.infer, we created a TypeScript type out of it.
The API call looks the same, but after getting the response, we call the parse() function to make sure the schema is correct. If there are any issues, we will know.
We could go even further and create a custom RxJs operator to make validation easier:
export function verifyResponseType<T extends z.ZodTypeAny>(zodObj: T) { return pipe(map((response) => zodObj.parse(response))); } @Injectable({ providedIn: 'root' }) export class ZodHttpService { private readonly http = inject(HttpClient); // usage fetchData(): Observable<ZodApiResponse> { return this.http .get('/api/example’) .pipe(verifyResponseType(ZodApiResponse)); } }
Now, if we receive data of a different schema, for example:
{ "differentFoo": 21, // front-end expects “foo”! "email": "example@test.com", "bar": [ { "baz": "abc", "id": "id1" }, { "baz": "def", "id": "id2" } ] }
We will see a clear error message in our browser’s console:
Summary
In most of the applications we write as front-end developers we rely on some outside information, like API responses. Unfortunately, such data is out of the reach of TypeScript, meaning we can’t be sure whether it matches our interfaces, or not. We can solve this issue in (at least) two ways: generate types based on the API or validate them in runtime. While the former is not always possible, as it requires some effort from the back-end team, the latter can be achieved using a tool like Zod.
Check out other articles in the technology bites category
Discover tips on how to make better use of technology in projects