DEV Community

Soren Sterling
Soren Sterling

Posted on

The TypeScript Bug Hiding Behind response.json()

There's a TypeScript pattern that looks perfectly safe at first glance:

type User = {
  id: number;
  displayName: string;
  roles: string[];
};

async function getUser(id: number): Promise<User> {
  const response = await fetch(`/api/users/${id}`);

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }

  return (await response.json()) as User;
}
Enter fullscreen mode Exit fullscreen mode

Nothing obviously wrong here.

The function has a return type. The HTTP status is checked. TypeScript knows what a User should look like.

But there's a problem hiding in that last line.

We haven't actually checked whether the server returned a valid user.

We've only told TypeScript to assume it did.

And those are two very different things.

The problem with trusting response.json()

Imagine the API normally returns:

{
  "id": 42,
  "displayName": "Ada",
  "roles": ["admin"]
}
Enter fullscreen mode Exit fullscreen mode

Everything works.

Now imagine a backend deployment changes the response:

{
  "id": 42,
  "displayName": "Ada",
  "roles": null
}
Enter fullscreen mode Exit fullscreen mode

Maybe a database field wasn't initialized. Maybe the backend has an inconsistent serializer. Maybe the API contract changed without the frontend being updated.

Whatever the reason, your TypeScript code still compiles.

Then somewhere else in the application:

const user = await getUser(42);

if (user.roles.includes("admin")) {
  // Show admin controls
}
Enter fullscreen mode Exit fullscreen mode

And suddenly:

TypeError: Cannot read properties of null
(reading 'includes')
Enter fullscreen mode Exit fullscreen mode

The compiler didn't warn us.

Not because TypeScript is broken, but because it never had access to the actual API response.

A type assertion isn't a runtime check

This is the line that creates the false sense of safety:

const user = data as User;
Enter fullscreen mode Exit fullscreen mode

The as User part doesn't validate anything.

It doesn't check property names, inspect nested objects, or verify that roles is an array.

Type assertions are removed when TypeScript is compiled to JavaScript.

At runtime, the data stays exactly as it was.

And there's another version of this problem that doesn't even require as:

const user: User = await response.json();
Enter fullscreen mode Exit fullscreen mode

Depending on the TypeScript library definitions you're using, response.json() can return any.

Since any bypasses many type checks, the assignment can compile without proving that the response matches User.

That's why I don't treat JSON parsing as validation.

Parsing answers one question:

Is this valid JSON?

Validation answers another:

Does this data have the shape my application expects?

We need both.

Start with unknown, not any

For data coming from outside the application, unknown is a useful starting point.

const raw: unknown = await response.json();
Enter fullscreen mode Exit fullscreen mode

Unlike any, unknown doesn't let us immediately access arbitrary properties.

Try this:

console.log(raw.displayName);
Enter fullscreen mode Exit fullscreen mode

TypeScript complains.

Good.

That forces us to establish what the value actually contains before using it.

This isn't about adding types everywhere. It's about being careful at the point where external data enters the application.

Once we've verified the data, the rest of our code can use normal TypeScript types.

A small runtime validator

For a simple object, we can validate it without installing a library.

First, a helper that checks whether a value is a non-null object and not an array:

function isObject(
  value: unknown
): value is Record<string, unknown> {
  return (
    typeof value === "object" &&
    value !== null &&
    !Array.isArray(value)
  );
}
Enter fullscreen mode Exit fullscreen mode

The null check matters because JavaScript considers typeof null to be "object".

The array check keeps arrays from passing as ordinary objects.

Now we can validate the actual user shape.

type User = {
  id: number;
  displayName: string;
  roles: string[];
};

function isUser(value: unknown): value is User {
  if (!isObject(value)) {
    return false;
  }

  return (
    typeof value.id === "number" &&
    Number.isInteger(value.id) &&
    typeof value.displayName === "string" &&
    Array.isArray(value.roles) &&
    value.roles.every(
      (role: unknown) => typeof role === "string"
    )
  );
}
Enter fullscreen mode Exit fullscreen mode

Notice that we don't just check whether roles is an array.

We check its contents too.

Without that final check, something like this would pass:

{
  "id": 42,
  "displayName": "Ada",
  "roles": [123, false, null]
}
Enter fullscreen mode Exit fullscreen mode

That's a valid JavaScript array.

It's not a valid string[].

Small details like this matter when you're validating external data.

Put the check at the boundary

Rather than calling isUser() throughout the application, I prefer validating the response once, as close to the API request as possible.

Here's the updated implementation:

function parseUser(value: unknown): User {
  if (!isUser(value)) {
    throw new Error(
      "Unexpected /api/users response shape"
    );
  }

  return value;
}

async function getUser(id: number): Promise<User> {
  const response = await fetch(`/api/users/${id}`);

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }

  const raw: unknown = await response.json();

  return parseUser(raw);
}
Enter fullscreen mode Exit fullscreen mode

Now getUser() makes a stronger promise.

It either returns data that passed our validation rules or throws an error.

And callers don't need to keep wondering whether the server returned roles: null.

That responsibility stays in one place.

There's a nice side effect here too: invalid data fails near the source, rather than causing an unrelated error several functions later.

A clear API response error is much easier to investigate than a mysterious Cannot read properties of null inside a UI component.

Test the validator with bad data

A validator shouldn't only be tested with the object we expect.

The interesting cases are the ones that almost look correct.

Here's a small test using the isUser() function above:

const samples: unknown[] = [
  {
    id: 42,
    displayName: "Ada",
    roles: ["admin"]
  },
  {
    id: "42",
    displayName: "Ada",
    roles: ["admin"]
  },
  {
    id: 43,
    displayName: "Lin",
    roles: null
  },
  null
];

console.log(samples.map(isUser));
Enter fullscreen mode Exit fullscreen mode

Output:

[ true, false, false, false ]
Enter fullscreen mode Exit fullscreen mode

The first object passes.

The second fails because the ID is a string.

The third fails because roles isn't an array.

And null fails without crashing the validator.

This might seem like a lot of effort for a small object, but the goal isn't to make every API response complicated.

It's to stop accidental assumptions from silently spreading through the codebase.

There's another catch: type guards can lie too

Here's a perfectly valid TypeScript function:

function isUser(value: unknown): value is User {
  return true;
}
Enter fullscreen mode Exit fullscreen mode

Seriously. TypeScript allows it.

The compiler trusts the type predicate you wrote. It doesn't prove that your implementation actually performs the checks.

A type guard is still ordinary JavaScript logic, and ordinary JavaScript logic can have bugs.

So runtime validation isn't about finding a magical way to make bad data impossible.

It's about writing explicit checks that can be tested.

For a three-field object, that's manageable.

For a deeply nested response with dozens of fields, maintaining everything by hand can become painful.

When a schema library makes more sense

For larger applications, I'd consider a runtime schema library such as Zod.

It lets you define validation rules and infer TypeScript types from the same schema.

For example:

import { z } from "zod";

const UserSchema = z.object({
  id: z.number().int(),
  displayName: z.string(),
  roles: z.array(z.string())
});

type User = z.infer<typeof UserSchema>;

const raw: unknown = await response.json();

const user = UserSchema.parse(raw);
Enter fullscreen mode Exit fullscreen mode

Here, parse() performs actual runtime validation.

If the data doesn't match, it throws a validation error.

The tradeoff is an extra dependency and an additional schema abstraction.

For a couple of small responses, a handwritten guard may be sufficient.

For a larger API with complex nested data, a schema library can save quite a bit of repetitive work.

I wouldn't turn this into a rule that every project must use a particular validation library.

The important thing is recognizing where validation is needed.

Not every property needs the same rules

One more detail worth mentioning: validating a structure isn't necessarily the same as validating its meaning.

Our isUser() function accepts:

{
  "id": -10,
  "displayName": "",
  "roles": []
}
Enter fullscreen mode Exit fullscreen mode

Structurally, this matches the type we declared.

But is a negative ID valid in the application? Should a display name be allowed to be empty?

That's a separate question.

If those constraints matter, they should be part of the validation logic as well.

For example:

Number.isInteger(value.id) && value.id > 0
Enter fullscreen mode Exit fullscreen mode

And:

typeof value.displayName === "string" &&
value.displayName.trim().length > 0
Enter fullscreen mode Exit fullscreen mode

The validator should reflect the actual contract, not just the TypeScript interface.

The part worth remembering

TypeScript is extremely useful for checking how data moves through a program.

But it can't guarantee that an external service will honor the structure we declared.

That boundary exists whether the data comes from:

  • An HTTP API
  • Browser local storage
  • A configuration file
  • A third-party webhook
  • A message queue

The practical approach is straightforward.

Treat external data as unknown, validate it at the boundary, and let TypeScript do its job after that.

You don't need to distrust every variable in your application.

You just need to be careful about the places where your application doesn't control the data.

And response.json() is one of those places.

Top comments (0)