Contents
21 Chapter

Typing fetch and API responses

The flow for handling external data safely — fetch is unknown, and zod schemas validate and type in one place. Plus the meaning fetch takes in an RSC environment, bridging into Part 4.

Chapter 20 covered almost every typing decision inside a component. This chapter is the last of Part 3. We look at the most dangerous, and therefore most often mishandled, area — typing data that came from outside.

The return of fetch().then(r => r.json()) is unknown. Asserting that it is a User does not make it a User. This chapter names that risk precisely and lays out how to narrow it safely. And in the last section we touch on the new meaning fetch takes in the Server Components environment of Part 4 (Modern Next.js), so Part 3 flows naturally into Part 4.

Why fetch + json() is unknown #

Look at the type signature of Response.json():

lib.dom.d.ts (excerpt)
interface Body {
  json(): Promise<any>;   // really, you should handle it as unknown for safety
}

It is defined as any, so anything passes if you just receive it. The good habit is to catch it as unknown at the first step.

receive external data as unknown
const res = await fetch('/api/me');
const data: unknown = await res.json();
// using data directly blocks almost every operation — you have to narrow

When you catch it as unknown, the next use site naturally asks “how are you going to narrow this?”. TypeScript is deliberately forcing the check.

A generic fetcher — common pattern, common risk #

The pattern that shows up most when you search the web is:

common generic fetcher
async function api<T>(url: string): Promise<T> {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return (await res.json()) as T;
}

// use site
type User = { id: string; name: string };
const me = await api<User>('/api/me');
// me is inferred as User

The code is clean. But that does not mean it is actually safe. as T only fools the compiler — nothing guarantees the server actually sent a User. Things like this happen often:

  • the server drops name and sends only nickname → the client reads me.name as undefined
  • id switches to a number → calling a string method throws at runtime
  • the backend is fine but the network slips in a different response (a proxy error HTML page)

TypeScript is a compile-time tool, so it cannot validate the data that actually arrives at runtime. This part needs a separate tool.

Narrowing with a user-defined type guard #

The lowest-dependency way to narrow is a type guard function.

type guard — no library
type User = { id: string; name: string };

function isUser(value: unknown): value is User {
  if (typeof value !== 'object' || value === null) return false;
  const v = value as Record<string, unknown>;
  return typeof v.id === 'string' && typeof v.name === 'string';
}

async function fetchMe(): Promise<User> {
  const res = await fetch('/api/me');
  const data: unknown = await res.json();
  if (!isUser(data)) throw new Error('invalid response');
  return data;
}

Upside: no dependencies. Downside: as fields grow, the guard function grows, and when the server spec changes you have to fix it by hand in many places. If your core entities are only two or three, hand-writing is workable.

zod — define once for types and runtime validation in one go #

When you have many fields or many shapes of response, a schema library like zod is almost a must. zod infers the type from the schema you wrote once and does runtime validation from the same code.

install zod
pnpm add zod
zod schema + inference
import { z } from 'zod';

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

type User = z.infer<typeof UserSchema>;
// User = { id: string; name: string; email: string; createdAt: string }

async function fetchMe(): Promise<User> {
  const res = await fetch('/api/me');
  const data: unknown = await res.json();
  return UserSchema.parse(data);   // throws if it does not match the schema
}

UserSchema.parse(data) is the key. If the incoming data does not match the schema anywhere, it throws; if it passes, the type narrows to User from that point on. The compile-time type and the runtime validation are defined in the same single place.

Transforms and defaults #

zod goes beyond pure validation to support transforms. If you want to receive a server’s ISO date string as a Date object:

date transform with zod
const PostSchema = z.object({
  id: z.string(),
  title: z.string(),
  createdAt: z.string().transform((s) => new Date(s)),
  views: z.number().default(0),
});

type Post = z.infer<typeof PostSchema>;
// { id: string; title: string; createdAt: Date; views: number }

You touch the schema in one place and your client code always receives Date objects. Conversion logic is collected in a single spot, which is easier to maintain.

Choices when reaching for zod #

ToolDependenciesValidationType inferenceWhen to use
as T (cast)nonefool the compilerquick / learning
Type guard (value is T)none✓ (hand-written)2~3 entities
zodone package✓ (z.infer)many entities, transform / default needed

This book picks zod as the default choice. We meet zod again in Chapter 27 (Server Actions and forms), and Appendix A (Migrating Old React) covers moving old PropTypes / hand-written validation to zod.

Generic fetcher + zod = the safe combination #

Combining api<T>’s cleanness with zod’s safety gives this:

a fetcher that forces validation
import { z } from 'zod';

async function apiGet<T>(url: string, schema: z.ZodSchema<T>): Promise<T> {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const data: unknown = await res.json();
  return schema.parse(data);
}

// use site
const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
});

const me = await apiGet('/api/me', UserSchema);
// me is inferred as z.infer<typeof UserSchema>

The caller is forced to provide a schema. The spot where as T fools the compiler is removed entirely. It is one more step of work, but you cut off the external-data risk at that point.

Using it from a component — the useEffect pattern #

The simplest pattern is calling fetch inside useEffect and putting the result into state. Combined with the discriminated-union model from Chapter 17, bundling the three states (request / success / failure) into one object reads cleanly.

request state as one union
type RequestState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

function MePage() {
  const [state, setState] = useState<RequestState<User>>({ status: 'idle' });

  useEffect(() => {
    let cancelled = false;
    setState({ status: 'loading' });

    apiGet('/api/me', UserSchema)
      .then((data) => {
        if (!cancelled) setState({ status: 'success', data });
      })
      .catch((err: unknown) => {
        if (!cancelled) {
          setState({
            status: 'error',
            error: err instanceof Error ? err.message : 'unknown error',
          });
        }
      });

    return () => {
      cancelled = true;
    };
  }, []);

  if (state.status === 'idle' || state.status === 'loading') return <p>Loading...</p>;
  if (state.status === 'error') return <p>Error: {state.error}</p>;

  return <p>{state.data.name}</p>;     // here data is narrowed to User
}

The key is the union with status as the discriminator. Inside the JSX branches, state.data is accessible only in the success branch, so accidents like “reading data when there is no data” are caught at compile time.

The cancelled flag is the cleanup pattern from Chapter 10 (useEffect). It prevents the memory leak / warning that comes from calling setState after the component unmounts.

The bigger picture — data fetching libraries and RSC #

The pattern above is great for learning, but in real apps writing fetch by hand inside useEffect is getting rarer. Caching, retries, and loading-state synchronization repeat every time. Most projects pick one of two paths.

1) TanStack Query — client-side data fetching #

Give it a fetcher function and it manages caching / retry / loading state for you. It works very well with TypeScript. Fits SPAs and routing where the client has to fetch data directly (dashboards, for example).

We do not adopt it directly in Parts 5 and 6 of this book, but the useEffect + fetch pattern above is actually a simplified version of what TanStack Query does internally. Getting comfortable with it makes picking up the library faster.

2) Server Components + Server Actions (Next.js) — the Part 4 model #

The model we cover in earnest in Part 4. Because fetching ends on the server, the client’s fetch code mostly disappears.

preview of the Part 4 RSC model
// app/users/[userId]/page.tsx — Server Component
async function UserPage({ params }: { params: { userId: string } }) {
  const res = await fetch(`https://api.example.com/users/${params.userId}`);
  const data: unknown = await res.json();
  const user = UserSchema.parse(data);   // validated on the server

  return <p>{user.name}</p>;
}

Three differences jump out.

  • No useState / useEffect. The function component is simply async.
  • No hand-managed loading state. Rendering starts only after the server’s await completes (Suspense fills the gap — Chapter 26).
  • Validation uses the same zod schema on the server. The UserSchema from this chapter is reused as-is.

The external-data validation layer that is zod stays valid in both the client area and the server area. The flow we set up in this chapter — external data → unknown → narrow with a schema → use type-safely — carries through unchanged in any environment.

The detailed RSC model and the pairing of data fetching / caching / Suspense are covered in Chapter 25 (Data fetching and caching) and Chapter 26 (Suspense and use()).

Environment variables and external config — same principle #

The environment variables and external config files you read on app startup carry exactly the same risk. process.env.API_URL has the type string | undefined, yet code often treats it as always-string and only blows up after deploy.

zod shines here too.

validate env too
const EnvSchema = z.object({
  API_URL: z.string().url(),
  NODE_ENV: z.enum(['development', 'production', 'test']),
});

export const env = EnvSchema.parse(process.env);
// env.API_URL is string, env.NODE_ENV is a narrow union

Validate once at app startup, and from then on you use the shape TypeScript guarantees. The point is to keep the app from even starting in a wrong environment.

Chapter 33 (Deploy and observability) covers the actual production env management procedure, and this pattern fits into it naturally.

Try it yourself #

A small example fetching user info from the GitHub API to get the flow of this chapter into your hands.

src/api.ts:

src/api.ts
import { z } from 'zod';

export const GitHubUserSchema = z.object({
  login: z.string(),
  id: z.number(),
  name: z.string().nullable(),
  bio: z.string().nullable(),
  public_repos: z.number(),
});

export type GitHubUser = z.infer<typeof GitHubUserSchema>;

export async function fetchGitHubUser(username: string): Promise<GitHubUser> {
  const res = await fetch(`https://api.github.com/users/${username}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const data: unknown = await res.json();
  return GitHubUserSchema.parse(data);
}

src/UserCard.tsx:

src/UserCard.tsx
import { useEffect, useState } from 'react';
import { fetchGitHubUser, type GitHubUser } from './api';

type RequestState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

function UserCard({ username }: { username: string }) {
  const [state, setState] = useState<RequestState<GitHubUser>>({ status: 'idle' });

  useEffect(() => {
    let cancelled = false;
    setState({ status: 'loading' });

    fetchGitHubUser(username)
      .then((data) => {
        if (!cancelled) setState({ status: 'success', data });
      })
      .catch((err: unknown) => {
        if (!cancelled) {
          setState({
            status: 'error',
            error: err instanceof Error ? err.message : 'unknown error',
          });
        }
      });

    return () => {
      cancelled = true;
    };
  }, [username]);

  if (state.status === 'idle' || state.status === 'loading') return <p>Loading...</p>;
  if (state.status === 'error') return <p>Error: {state.error}</p>;

  const { data } = state;
  return (
    <div style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px' }}>
      <h2>{data.login}{data.name ? ` (${data.name})` : ''}</h2>
      <p>Repositories: {data.public_repos}</p>
      {data.bio && <p>{data.bio}</p>}
    </div>
  );
}

export default UserCard;

src/App.tsx:

src/App.tsx
import UserCard from './UserCard';

function App() {
  return (
    <>
      <UserCard username="curtis" />
      <UserCard username="nonexistentuserxyzabc" />
    </>
  );
}

export default App;

Save and confirm it works. The first card shows real data; the second shows the error message. Try misdefining a field in GitHubUserSchema (for example, id: z.string()) and observe how a data mismatch with the schema is caught.

Exercises #

  1. Add a transform to GitHubUserSchema so bio: z.string().nullable().transform((b) => b ?? 'no bio'), and inside the component always treat it as string. Get the feel for how zod’s transform simplifies client code.
  2. Build a generic fetcher like apiGet<T>(url, schema) and call it for two different entities (GitHubUser, GitHubRepo) using the same fetcher. Feel for yourself how the caller is forced to provide a schema.
  3. The env validation pattern. Build a small module that validates import.meta.env.VITE_API_URL with EnvSchema = z.object({ VITE_API_URL: z.string().url() }). Put a bad value (not a URL) in .env and confirm that the app errors out at startup.

In one line: Receive external data as unknown. The as T cast only fools the compiler — it is not safe. Defining validation + type inference (z.infer) in one place with a zod schema makes compile time and runtime safe together. Bundle request state into an idle | loading | success | error discriminated union. In the Part 4 RSC environment, useEffect + fetch disappears and the server fetches directly, but the zod validation layer survives unchanged.

Next chapter #

This chapter closes out Part 3: React with TypeScript. Over six chapters we have put TypeScript’s safety net on top of the React core building blocks from Parts 1 and 2. Props, hooks, events, forms, Context, external data — almost every typing decision you meet building a new component is now in your hands.

From the next Chapter 22 Why Next.js and Server Components on, Part 4 begins. The RSC model we previewed in the last section of this chapter — where the server fetches directly and the client’s useEffect + fetch mostly disappears — gets its background and turning points organized in one place. This is the pivotal part of the book.

X