Skip to content

Hands‑on: Build a Generic Data‑Fetching Wrapper with Typed Responses

This hands‑on exercise brings together everything from Module 6 so far—generic functions, constraints, default type parameters, and generic classes/services.

You’ll build a fully typed data‑fetching wrapper, similar to what real libraries like Axios, React Query, SWR, and tRPC do internally.

The goal is to show how generics let you:

  • fetch any kind of data
  • preserve full type information
  • validate and transform responses
  • build reusable, type‑safe abstractions

Let’s build it step by step.


Step 1 — Start with a Basic Fetch Wrapper

Create a file:

fetcher.ts

export async function fetchJSON<T = unknown>(url: string): Promise<T> {
  const res = await fetch(url)

  if (!res.ok) {
    throw new Error(`Request failed: ${res.status}`)
  }

  const data = await res.json()
  return data as T
}

What this does

  • T is a generic type parameter
  • default type is unknown
  • caller can specify the expected response type
  • TypeScript enforces it at compile time

Usage

type User = { id: number; name: string }

const user = await fetchJSON<User>("/api/user")

Now user is fully typed.


Step 2 — Add Constraints for Safer Parsing

Let’s enforce that the response must be an object.

export async function fetchObject<T extends object>(
  url: string
): Promise<T> {
  const data = await fetchJSON<unknown>(url)

  if (typeof data !== "object" || data === null) {
    throw new Error("Expected an object response")
  }

  return data as T
}

Why this matters

  • prevents runtime errors
  • ensures the caller gets an object
  • uses a generic constraint (T extends object)

Step 3 — Build a Generic API Client Class

Create:

apiClient.ts

export class ApiClient {
  constructor(private baseUrl: string) {}

  async get<T = unknown>(path: string): Promise<T> {
    return fetchJSON<T>(`${this.baseUrl}${path}`)
  }

  async post<T = unknown, B = unknown>(path: string, body: B): Promise<T> {
    const res = await fetch(`${this.baseUrl}${path}`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body)
    })

    if (!res.ok) {
      throw new Error(`POST failed: ${res.status}`)
    }

    return res.json() as Promise<T>
  }
}

What’s happening

  • get<T> → typed GET requests
  • post<T, B> → typed POST requests
  • T = response type
  • B = request body type
  • both have default type parameters

This is exactly how real HTTP clients are typed.


Step 4 — Use the Typed API Client

Create:

index.ts

import { ApiClient } from "./apiClient"

const api = new ApiClient("https://example.com")

type User = { id: number; name: string }
type NewUser = { name: string }

async function main() {
  const user = await api.get<User>("/user/1")
  console.log(user.id, user.name)

  const created = await api.post<User, NewUser>("/user", { name: "Alice" })
  console.log(created.id)
}

main()

TypeScript now ensures:

  • GET returns a User
  • POST body must match NewUser
  • POST response must match User

This is end‑to‑end type safety.


Step 5 — Add a Generic “Result Wrapper”

Many APIs wrap responses like this:

{
  "success": true,
  "data": { ... }
}

Let’s model that.

result.ts

export interface ApiResult<T> {
  success: boolean
  data: T
}

Update the client:

async getResult<T>(path: string): Promise<ApiResult<T>> {
  return this.get<ApiResult<T>>(path)
}

Usage:

const result = await api.getResult<User>("/user/1")

if (result.success) {
  console.log(result.data.name)
}

Generics let you wrap types inside types—cleanly and safely.


Step 6 — Add a Generic Error Handler (Optional)

Let’s add a typed error result:

export type ApiResponse<T> =
  | { ok: true; data: T }
  | { ok: false; error: string }

A generic parser:

export async function fetchSafe<T>(
  url: string
): Promise<ApiResponse<T>> {
  try {
    const data = await fetchJSON<T>(url)
    return { ok: true, data }
  } catch (err) {
    return { ok: false, error: (err as Error).message }
  }
}

Usage:

const res = await fetchSafe<User>("/user/1")

if (res.ok) {
  console.log(res.data.name)
} else {
  console.error("Error:", res.error)
}

This is the foundation of typed error handling in real apps.


Step 7 — Add a Generic Cache Layer (Bonus)

class CachedFetcher<T> {
  private cache = new Map<string, T>()

  constructor(private fetchFn: (url: string) => Promise<T>) {}

  async get(url: string): Promise<T> {
    if (this.cache.has(url)) {
      return this.cache.get(url)!
    }

    const data = await this.fetchFn(url)
    this.cache.set(url, data)
    return data
  }
}

Usage:

const cachedUserFetcher = new CachedFetcher<User>(url => fetchJSON<User>(url))

const user = await cachedUserFetcher.get("/user/1")

Generics let you build reusable infrastructure like this with zero duplication.


What You Learned

This hands‑on exercise demonstrates how generics power real‑world TypeScript systems:

1. Generic functions

  • fetchJSON<T> preserves response types
  • constraints ensure safe parsing

2. Generic classes/services

  • typed GET/POST methods
  • typed request bodies
  • typed response wrappers

3. Default type parameters

  • ergonomic APIs
  • flexible usage

4. Composing generics

  • ApiResult<T>
  • ApiResponse<T>
  • CachedFetcher<T>

This is exactly how modern TypeScript frameworks build safe, expressive data layers.