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¶
Tis 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 requestspost<T, B>→ typed POST requestsT= response typeB= 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.