DEV Community

Cover image for Common Pitfalls in TypeScript with HTTP Calls
Gérôme Grignon
Gérôme Grignon

Posted on • Originally published at gerome.dev

Common Pitfalls in TypeScript with HTTP Calls

When working with TypeScript to make HTTP calls, it's easy to fall into certain traps that can lead to unexpected behavior and bugs. In this blog post, we'll discuss three common errors and how to avoid them.

Type Mismatch in HTTP Response

Issue: Typing your HTTP call response in TypeScript doesn't guarantee that the actual response body will match your type.

Explanation: TypeScript's type system only works at compile-time. It helps you catch type errors while writing code, but it doesn't enforce type correctness at runtime. This means that even if you define a type for your HTTP response, there's no guarantee that the server will return data in the expected format.

TypeScript is only about the code you write!

Example:

interface User {
  id: number;
  firstName: string;
  email: string;
}

async function fetchUser(userId: number): Promise<User> {
  const response = await fetch(`https://api.example.com/users/${userId}`);
  const data: User = await response.json();
  return data;
}
Enter fullscreen mode Exit fullscreen mode

In the above example, if the server returns a response where the firstName field is missing or using a different format like first_name, TypeScript won't warn you. The mismatch will only become apparent at runtime.

Solution: Use runtime validation to ensure the response matches the expected type. Libraries like valibot or zod can help with this.
Rather than creating an interface, use a type inferred from a schema and parse the response data with the schema.

Example with zod:

import { z } from "zod";

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string(),
});

type userSchema = z.infer<typeof UserSchema>;

async function fetchUser(userId: number): Promise<User> {
  const response = await fetch(`https://api.example.com/users/${userId}`);
  const data = await response.json();
  return UserSchema.parse(data); // Runtime validation
}
Enter fullscreen mode Exit fullscreen mode

Nested Data in Response

Issue: Some APIs nest the actual data in a response wrapper, often under a data property. If you don't check the response structure, you might end up with incorrect data handling.
It often happen with paginated data where the top level object contains metadata like total, page, perPage, etc.

Besides the requirement for pagination, you might struggle due to your company's API design guidelines. Some people use data while others use result or response.

Example:

{
  "data": {
    "id": 1,
    "name": "Alice",
    "email": "alice@mail.com"
  },
  "total": 1,
  "page": 1
}
Enter fullscreen mode Exit fullscreen mode

In this example, the server response is expected to have a data property that contains the actual user object. If the structure changes, you won't be notified by TypeScript.

Solution: Always check and validate the response structure. Once again using a library like zod can help with this.

Example:

import { z } from "zod";

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string(),
});

const ResponseSchema = z.object({
  data: UserSchema,
});

type User = z.infer<typeof UserSchema>;

async function fetchUser(userId: number): Promise<User> {
  const response = await fetch(`https://api.example.com/users/${userId}`);
  const data = await response.json();
  const parsedData = ResponseSchema.parse(data);
  return parsedData.data;
}
Enter fullscreen mode Exit fullscreen mode

Class Instances from HTTP Responses

Issue: Using a class to type your HTTP response doesn't automatically create instances of that class. The response data will just be plain objects.

Example:

class User {
    constructor(
        public id: number,
        public firstName: string,
        public lastName: string
    ) {
    }

    getFullName(): string {
        return `${this.firstName} <${this.lastName}>`;
    }
}

async function fetchUser(userId: number): Promise<User> {
    const response = await fetch(`https://api.example.com/users/${userId}`);
    const data = await response.json();
    return data as User;
}
Enter fullscreen mode Exit fullscreen mode

In the example above, using getFullName on the returned User object will result in a runtime error because data is not an instance of the User class.

Solution: Manually instantiate the class with the response data.

Example:

class User {
    constructor(
        public id: number,
        public firstName: string,
        public lastName: string
    ) {
    }

    getFullName(): string {
        return `${this.firstName} <${this.lastName}>`;
    }
}

async function fetchUser(userId: number): Promise<User> {
  const response = await fetch(`https://api.example.com/users/${userId}`);
  const data = await response.json();
  return new User(data.id, data.name, data.email);
}
Enter fullscreen mode Exit fullscreen mode

Now, the User instance will have the getDisplayName method available.

Conclusion

TypeScript is a powerful tool for catching type errors at compile-time, but it doesn't enforce type safety at runtime. When dealing with HTTP responses, always validate the response structure and instantiate classes manually to avoid common pitfalls. Using libraries like zod for runtime validation can greatly enhance the reliability of your TypeScript applications.

Top comments (0)