TypeScript + React in Practice #6: Fetch and API response typing

3 min read

In #5 Context and generic components we covered nearly every typing decision inside components. The final post addresses the riskiest part — and therefore the most error-prone — typing data from outside.

The return of fetch().then(r => r.json()) is unknown. Just because we insist it’s a User doesn’t make it actually a User. This post pinpoints the danger and organizes how to narrow it safely.

Why is fetch + json() unknown? #

The type signature of Response.json() looks like:

lib.dom.d.ts (excerpt)
interface Body {
  json(): Promise<any>;   // safer to handle as unknown
}

It’s defined as any, so anything passes through if you take it as-is. The good habit is to receive it as unknown first.

External data should be unknown
const res = await fetch('/api/me');
const data: unknown = await res.json();
// using data directly blocks almost everything — must narrow

Receiving as unknown naturally raises the question “how will you narrow it?” at the use site. TypeScript intentionally enforces validation here.

Generic fetcher — common pattern, with risks #

The most common pattern found when searching the web:

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 clean doesn’t mean safe. as T simply tricks the compiler — there’s no guarantee the server actually returned the User shape. Common consequences:

  • Server omits name and only sends nickname → client reads me.name and gets undefined
  • id becomes a number → calling string methods crashes at runtime
  • Backend is fine, but the network injects a different response (a proxy error HTML page)

TypeScript is a compile-time tool, so it can’t validate the data that actually arrives at runtime. This is where a separate tool is needed.

Narrow with a user-defined type guard #

The lowest-dependency way to narrow is a type guard function, the pattern covered in basics #4 union, literal, narrowing.

Type guard — without a 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('잘못된 응답');
  return data;
}

Pro: zero dependencies. Con: more fields means a longer guard function, and changes in the server spec require manual edits in many places. If you have only two or three core entities, hand-writing is feasible.

zod — define once for both type + runtime validation #

Once fields multiply or response variants increase, zod or another schema library is nearly mandatory. zod infers types from a schema you write once and uses the same code for runtime validation.

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 doesn't match
}

UserSchema.parse(data) is the trick. If incoming data doesn’t match the schema in even one place, it throws; if it passes, the type is narrowed to User from that point. Compile-time type and runtime validation are defined in one place.

Transforms and defaults #

zod supports more than simple validation; it also transforms. To convert a date that arrives as an ISO string into a Date object:

Convert dates 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 }

Adjust the schema in one place and the client always receives a Date object. Transformation logic gathers in one place; maintenance is easier.

Generic fetcher + zod = safe combination #

Combine the cleanness of api<T> with the safety of zod:

Fetcher that enforces 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 specify a schema. There’s no place to fool the compiler with as T anymore. Slightly more work, but you’ve blocked the danger of external data at that point.

Using it in components — the useEffect pattern #

The simplest pattern is to call fetch inside useEffect and store the result in state. As with the form patterns in #4, bundling request/success/failure as a single object is the clean approach.

Bundle request states as a 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 : '알 수 없는 오류',
          });
        }
      });

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

  if (state.status === 'idle' || state.status === 'loading') return <p>로딩 ...</p>;
  if (state.status === 'error') return <p>에러: {state.error}</p>;

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

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

The cancelled flag is the cleanup pattern from Modern React #4. It prevents memory leaks/warnings from setState being called after the component unmounts.

A bigger picture — data fetching libraries #

The pattern above is good for learning, but in real apps you write fetching directly with useEffect less and less. Cache, retries, and loading-state synchronization have to be handled from scratch every time. In practice, you’ll go one of two ways.

  • TanStack Query — the standard for client-side data fetching. Give it a fetcher function and it manages caching/retries/loading state. Plays very well with TypeScript.
  • Server Components + Server Actions (Next.js) — the pattern from the Modern React series. Fetching ends on the server, so client fetch code mostly disappears.

Either way, the flow stays the same — external data → unknown → narrow with a schema → use type-safely. A validation layer like zod is valuable in any environment.

Same principle for env vars and external configs #

Environment variables and external config files read at app startup carry the same risk. process.env.API_URL is string | undefined, but in practice we tend to treat it as always a string and run into issues post-deploy.

zod shines here too.

Validate env
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 rely on the shape TypeScript guarantees thereafter. The goal is to make the app refuse to start in a misconfigured environment.

Wrapping up the series #

Recapping each of the six posts in one line:

  1. Setup — Vite + react-ts, strict mode, trust inference (#1)
  2. props/childrentype, ComponentProps, discriminated union, ReactNode (#2)
  3. hooks — trust inference, annotate when starting from null, reducer is union + exhaustiveness (#3)
  4. events and formsReact.XXXEvent<Element>, currentTarget, narrow FormData (#4)
  5. Context and genericsnull + helper, separate state/dispatch, polymorphic component (#5)
  6. External datafetch is unknown, validate and infer types with zod schema

Once the flow sinks in, you’ll see the same decision pattern repeat every time you build a new component. That’s the moment TypeScript stops feeling clunky and becomes a reliable coworker.

The next series goes one step deeper into TypeScript itself — conditional types, mapped types, infer, type guard patterns. Tools for solving the harder typing problems you meet in real work.

X