Common Pitfalls in TypeScript with HTTP Calls
Learn about common pitfalls in TypeScript when making HTTP calls and how to avoid them.
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;
}
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
}
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
}
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;
}
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;
}
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);
}
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.