C
TypeScript/Async/Lesson 05

Fetch API — Generic Helper Pattern for Pinning Response Types

30 min·theory
This chapter
5/7
TypeScript

Fetch API — Generic Helper Pattern for Pinning Response Types

💡 Why Learn This? — fetch Is Always a Leak Point for any

🎯 The result of `fetch().then(r => r.json())` is `Promise` by default — this is where the type system breaks down.
💼 With TypeScript, you only need to write a generic helper `fetchJson()` once, and it will safely propagate typed results across all API calls.
Combined with runtime validation (zod or valibot), you can even guarantee that external data actually matches the expected type.
🔗 Error responses (`!res.ok`), network errors, and parsing errors — separating these three cases is the real-world pattern.
🏢 실무에서는
Even in Next.js Server Components, `await fetch()` is at the core. Because you use the same data shape in many places, the `User` and `Post` interfaces you define once must flow consistently from fetch results all the way to component props. If you don't pin the type at the fetch boundary, that consistency breaks down.

Response · res.json() · Generic Helper

1. The type of Response is known, but body is any

ts
const res: Response = await fetch('/api/users/1');
// res methods (json·text·blob·formData) are known to TS, but
// res.json() returns Promise<any> — the shape of body is unknown
const data = await res.json(); // data: any

2. The most common pattern — as Promise<User>

ts
const user = await fetch('/api/users/1').then(
  (r) => r.json() as Promise<User>,
);

Simple, but it can lie — even if the server suddenly returns a different shape, it passes compilation. Fine for small projects.

3. Generic helper — reusable, casts in one place

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

// Call sites
const user = await fetchJson<User>('/api/users/1');
const posts = await fetchJson<Post[]>('/api/posts');

4. Truly safe — combine with runtime validation

ts
import { z } from 'zod';

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
});
type User = z.infer<typeof UserSchema>;

async function fetchUser(id: number): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const raw = await res.json();
  return UserSchema.parse(raw); // throws here if shape doesn't match
}

Since the actual shape is validated at runtime, it cannot lie. Recommended when communicating with external APIs.

💻 🅰️ JS Approach — Result of res.json() is any
// ❌ JS — The shape of the fetch result is opaque

async function getUser(id) {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error('Failed');
  return res.json();
}

async function main() {
  const user = await getUser(42);
  // IDE doesn't know the fields of user
  console.log(user.name);   // OK if lucky
  console.log(user.naem);   // Typo — undefined
  console.log(user.profile.avatar); // Nested access — don't know if safe
}
main();
💻 🅱️ TS Approach — Generic Helper + Pinning the Response Type
// ✅ TS — Generic helper + explicit response type

interface User {
  id: number;
  name: string;
  email: string;
  profile: { avatar: string };
}
interface Post {
  id: number;
  title: string;
  authorId: number;
}

class HttpError extends Error {
  constructor(message: string, public status: number) {
    super(message);
    this.name = 'HttpError';
  }
}

// Defined once — reusable across the entire project
async function fetchJson<T>(
  url: string,
  init?: RequestInit,
): Promise<T> {
  const res = await fetch(url, init);
  if (!res.ok) {
    throw new HttpError(`${res.status} ${res.statusText}`, res.status);
  }
  return res.json() as Promise<T>;
}

// Caller — just specify the type argument
async function main(): Promise<void> {
  try {
    const user = await fetchJson<User>('/api/users/1');
    console.log(user.name);             // ✅
    console.log(user.profile.avatar);   // ✅ Nested access is safe
    // console.log(user.naem);          // ❌ Compilation rejected

    const posts = await fetchJson<Post[]>('/api/posts');
    posts.forEach((p) => console.log(p.title));

    // POST with the same helper
    const created = await fetchJson<Post>('/api/posts', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title: 'hi', authorId: 1 }),
    });
    console.log('created:', created.id);
  } catch (err) {
    if (err instanceof HttpError) {
      console.log(`HTTP ${err.status}: ${err.message}`);
    } else if (err instanceof Error) {
      console.log('Network error:', err.message);
    }
  }
}
main();

💡 💡 5 Practical fetch + TS Patterns

1. One fetchJson helper per project
Consolidating fetch into a single entry point lets you manage caching, logging, and error policy in one place.

2. as Promise is a cast that 'trusts the server'
For external APIs, use runtime validation like zod to verify the shape safely.

3. Preserve status with a custom error like HttpError

ts
class HttpError extends Error {
  constructor(msg: string, public status: number) { super(msg); }
}

Enables different UI handling per status (401 → login, 403 → permission notice).

4. RequestInit is a standard type — TS knows it

ts
fetch(url, {
  method: 'POST',                 // ✅ TS infers 'POST'|'GET'|...
  headers: { 'Content-Type': 'application/json' }, // ✅
  body: JSON.stringify(payload),
});

5. Next.js fetch accepts cache options

ts
fetch(url, { cache: 'no-store' });                  // always fresh
fetch(url, { next: { revalidate: 60 } });           // 60-second ISR

Next.js extends the standard fetch RequestInit, and the types are extended along with it.

⚡ Try It Yourself — fetchJson Helper (Mock)

The actual fetch makes an external call, so it is mocked here. This is just to get a feel for the helper's flow.
✏️ JS 코드
📟 Console output
▶ Press the Run button
⚠️ Runs in a browser sandbox — only console.log() is supported; alert/fetch are not.

Check Your Understanding

What is the weakness of `async function fetchJson<T>(url: string): Promise<T> { ... return res.json() as Promise<T>; }`?
💡 A type assertion (`as Promise<T>`) is just telling the compiler 'trust me' — it is not runtime validation. Even if the server suddenly returns a different shape, compilation will still pass. When communicating with external APIs, it is safer to validate the result of `res.json()` one more time using a library such as zod or valibot.
Fetch API — Generic Helper fetchJson<T> - TypeScript