Type-safe API responses in Angular with Zod

Typesafe API reposnses in Angular with Zod

Table of Contents

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?

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.

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:



Check out other articles in the technology bites category

Discover tips on how to make better use of technology in projects

Do you have any questions?