Practical Case Study: Typing a Data Fetching Hook
Introduction
Data fetching is one of the most repeated patterns in front-end applications. Yet, when moving beyond trivial examples, typing a reusable data-fetching hook in TypeScript becomes surprisingly tricky. You want a hook that is ergonomic for consumers, infers types where possible, preserves safety across error states, and composes well with caching or revalidation strategies. This case study walks you through building a real-world, strongly-typed data fetching hook step-by-step so that intermediate TypeScript developers can learn patterns, pitfalls, and practical solutions.
In this article you'll learn: how to design the hook API surface, add type-safe generics and inference, handle loading/error/success states with discriminated unions, type fetcher functions, integrate simple caching and stale-while-revalidate behavior, test your types in a monorepo or package, and tune compiler and bundler settings for the fastest developer feedback. Examples are based on React hooks but the typing patterns apply to other frameworks (including Deno-based servers) as well.
We'll provide numerous code snippets with explanations, show how to keep ergonomics high for consumers, and include troubleshooting notes for common type-level errors. If you want to ship a robust hook that multiple teams use without runtime surprises or awkward type assertions, this walkthrough will give you a practical blueprint.
Background & Context
TypeScript provides static guarantees but those guarantees are only as useful as your type design. A data-fetching hook sits at the boundary of asynchronous code, network errors, caching strategies, and UI components. The typical challenges are: representing three-state asynchronous flows (loading/success/error) in a type-safe way; enabling inference so callers rarely need to specify generics; composing with different fetcher implementations (fetch, axios, GraphQL clients); preventing accidental undefined access; and ensuring the hook scales across a codebase.
Good type design mitigates bugs, improves DX (developer experience), and makes refactors safer. This tutorial focuses on generics, discriminated unions, conditional types, and inference patterns that let you write a single hook that remains expressive and safe. We'll also touch on build and toolchain considerations to keep iteration fast.
Key Takeaways
- Design a clear API surface that balances ergonomics and type safety.
- Use generics and conditional types so consumers rarely need explicit type arguments.
- Represent status with discriminated unions to force exhaustive handling in UI.
- Type fetcher functions to allow interchangeable fetch implementations.
- Add lightweight caching and stale-while-revalidate without sacrificing types.
- Test type behavior and manage shared types across packages or monorepos.
- Tune TypeScript and bundler settings for best developer productivity.
Prerequisites & Setup
You'll need:
- Node.js and a modern package manager (npm/yarn/pnpm)
- TypeScript >= 4.1 (conditional and infer types used heavily)
- A React setup (the examples use hooks, but patterns generalize)
- Optional: bundler or build tool (esbuild/swc/Rollup/webpack) — see performance notes
Recommended tsconfig flags for safety/ergonomics include strict, noImplicitAny, and consider noUncheckedIndexedAccess if you want stricter indexing checks. For faster TypeScript builds check options and toolchains described in our guide on Using esbuild or swc for Faster TypeScript Compilation.
Main Tutorial Sections
1) Design goals and API surface
Before coding, define what the hook should do. Minimal goals:
- Provide a simple call site like: const { data, error, status } = useFetch(url, options)
- Allow typed results: useFetch
(url) - Support custom fetcher functions (fetch, axios, GraphQL)
- Expose revalidate/refetch and optional caching behavior
Design decisions early: prefer return shapes that are easy to pattern-match (discriminated unions) and make consumer code explicit (no hidden nulls). Good project-level organization makes this hook easy to maintain — for patterns and organization across large codebases, see Code Organization Patterns for TypeScript Applications.
2) Basic untyped hook implementation
Start simple to verify runtime behavior before investing in types:
import { useState, useEffect, useCallback } from 'react'
function useFetchRaw(url: string) {
const [data, setData] = useState<any>(null)
const [status, setStatus] = useState<'idle'|'loading'|'success'|'error'>('idle')
const [error, setError] = useState<any>(null)
const fetcher = useCallback(async () => {
setStatus('loading')
try {
const res = await fetch(url)
const json = await res.json()
setData(json)
setStatus('success')
} catch (err) {
setError(err)
setStatus('error')
}
}, [url])
useEffect(() => { fetcher() }, [fetcher])
return { data, status, error, refetch: fetcher }
}This verifies behavior: keep this as a baseline when you add types.
3) Adding TypeScript generics for response types
Now make the hook generic so callers can declare the expected response type. The challenge: allow inference where possible and keep ergonomics high.
import { useState, useEffect, useCallback } from 'react'
type FetchStatus = 'idle' | 'loading' | 'success' | 'error'
export function useFetch<T = unknown>(url: string) {
const [data, setData] = useState<T | null>(null)
const [status, setStatus] = useState<FetchStatus>('idle')
const [error, setError] = useState<unknown | null>(null)
const refetch = useCallback(async () => {
setStatus('loading')
try {
const res = await fetch(url)
const json = (await res.json()) as T
setData(json)
setStatus('success')
} catch (err) {
setError(err)
setStatus('error')
}
}, [url])
useEffect(() => { refetch() }, [refetch])
return { data, status, error, refetch }
}Consumers can call: const { data } = useFetch
4) Handling errors and status with discriminated unions
A better approach than nullable fields is a discriminated union that forces exhaustive handling in components:
type Idle = { status: 'idle' }
type Loading = { status: 'loading' }
type Success<T> = { status: 'success'; data: T }
type ErrorState = { status: 'error'; error: unknown }
type Result<T> = Idle | Loading | Success<T> | ErrorState
export function useFetch2<T = unknown>(url: string): { result: Result<T>; refetch: () => Promise<void> } {
// implementation similar to above that returns { result, refetch }
}In UI:
const { result } = useFetch2<User>('/api/me')
switch (result.status) {
case 'idle': return <div />
case 'loading': return <Spinner />
case 'error': return <ErrorView error={result.error} />
case 'success': return <Profile user={result.data} />
}This pattern reduces runtime surprises and improves maintainability. To manage side-effects in type-safe ways, consider patterns discussed in Achieving Type Purity and Side-Effect Management in TypeScript.
5) Typing fetcher functions and inference from parameters
A big win is allowing callers to pass custom fetcher functions while still inferring the response type:
type Fetcher<Args extends any[], R> = (...args: Args) => Promise<R>
export function useFetcher<Args extends any[], R>(fetcher: Fetcher<Args, R>, ...args: Args) {
const [state, setState] = useState<Result<R>>({ status: 'idle' })
const run = useCallback(async () => {
setState({ status: 'loading' })
try {
const data = await fetcher(...args)
setState({ status: 'success', data })
} catch (error) {
setState({ status: 'error', error })
}
}, [fetcher, ...args])
useEffect(() => { run() }, [run])
return { state, refetch: run }
}Now, if you pass a typed fetcher (e.g. an axios wrapper typed as Promise
6) Typing caching and stale-while-revalidate (SWR) behavior
Adding caching complicates types slightly because you must represent cached values vs newly fetched values. Keep the type surface similar and let the implementation be a separate module:
type CacheKey = string
const simpleCache = new Map<CacheKey, unknown>()
export function useSWR<T = unknown>(key: CacheKey, fetcher: () => Promise<T>) {
const cached = simpleCache.get(key) as T | undefined
const [state, setState] = useState<Result<T>>(cached ? { status: 'success', data: cached } : { status: 'idle' })
const revalidate = useCallback(async () => {
setState({ status: 'loading' })
try {
const data = await fetcher()
simpleCache.set(key, data)
setState({ status: 'success', data })
} catch (error) {
setState({ status: 'error', error })
}
}, [key, fetcher])
useEffect(() => { if (!cached) revalidate() }, [cached, revalidate])
return { state, revalidate }
}For advanced caching strategies (service workers, background sync), you can combine this hook with technologies covered in Using TypeScript with Service Workers: A Practical Guide.
7) Advanced type inference using conditional types and infer
When you want the hook to infer the type from the fetcher automatically and provide helper overloads, conditional types help:
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T
export function useAutoFetch<F extends (...args: any) => Promise<any>>(fetcher: F, ...args: Parameters<F>) {
type Data = UnwrapPromise<ReturnType<F>>
const [state, setState] = useState<Result<Data>>({ status: 'idle' })
// fetch logic uses fetcher(...args) and sets typed state
}This pattern is powerful because if a consumer registers a fetcher typed as Promise<{ id: number }>, the hook's Data type becomes { id: number } automatically. Conditional and infer-based utilities are covered in depth in puzzle-focused articles like Solving TypeScript Type Challenges and Puzzles.
8) Integrating with bundlers and compiler flags
Large projects need fast feedback. When developing typed hooks, frequent changes to types and code can slow builds. Consider using fast toolchains — our guide on Using esbuild or swc for Faster TypeScript Compilation outlines strategies to shrink compile times. Also tune TypeScript compiler flags for a balance between safety and developer speed; see Advanced TypeScript Compiler Flags and Their Impact for concrete guidance.
When bundling for production, tree-shake unused cache/manager code and keep the hook's runtime minimal. If you use Rollup, see tips in Using Rollup with TypeScript: An Intermediate Guide, and for webpack-based builds consult Using Webpack with TypeScript: ts-loader and awesome-typescript-loader Deep Dive.
9) Testing, monorepos and publishing types
If you plan to publish the hook as a package or share across teams, you must think about type distribution. Two common strategies:
- Ship .d.ts files in your package (configured in tsconfig and package.json "types" field)
- Maintain a central types repository in a monorepo
If you share types across workspaces, follow patterns in Managing Types in a Monorepo with TypeScript. When contributing typings to ecosystem repositories, the guide Contributing to DefinitelyTyped: A Practical Guide for Intermediate Developers is a useful reference.
Testing tips: use tsd or TypeScript's own compiler in tests to assert that example call sites compile (and that misuses fail). Also write runtime tests for caching and refetch behavior using jest or your preferred test runner.
Advanced Techniques
Once the basic hook is typed, consider these optimizations and advanced patterns:
- API client-driven inference: if you have a typed API client, expose the client's response types through your hook using mapped types and inference to reduce duplication.
- Layered composability: separate concerns into small typed primitives (useFetcher, useCache, useSWR) so each piece is easier to test and reason about.
- Type-level caches: build a typed registry (Map<string, unknown>) with wrapper functions that enforce types during get/set operations.
- Memoized typed fetchers: use useCallback + generic constraints to avoid breaking referential equality, and type the memoization functions to preserve inference.
- Performance: reduce state updates by batching or using refs for intermediate mutable state to avoid excessive renders. For broader performance advice related to TypeScript and runtime overhead, see Performance Considerations: Runtime Overhead of TypeScript (Minimal) and how compile speed affects iteration in Performance Considerations: TypeScript Compilation Speed.
These techniques keep both type-safety and runtime performance high as your codebase grows.
Best Practices & Common Pitfalls
Dos:
- Do prefer discriminated unions over nullable fields for status handling — they force explicit handling.
- Do type fetchers and use inference so consumers rarely declare generics.
- Do separate concerns (fetcher, caching, effect orchestration) into small modules.
- Do add compile-time tests (tsd) to protect public API surface.
Don'ts / Pitfalls:
- Don't rely on any or unknown escapes to silence type errors — that erodes the benefits of TypeScript.
- Avoid heavily constraining generics that make the hook hard to use (overly complex call signatures confuse consumers).
- Be careful with dependency arrays that include functions; prefer stable references or memoization.
Tooling tips: integrate formatting and linting (Prettier and ESLint) to keep code consistent and catch obvious issues during development. See our guides on Integrating Prettier with TypeScript — Specific Config and Integrating ESLint with TypeScript Projects (Specific Rules) for configuration examples.
Troubleshooting common type errors:
- "Type 'X' is not assignable to type 'Y'": often indicates a mismatch between declared generic default and inferred type — explicitly annotate the fetcher or the call site.
- "No overload matches this call": check parameter tuple typing when using variadic fetcher signatures.
- Excessive compile time: consider isolatedModules or use faster toolchains like esbuild/swc during development.
Real-World Applications
A well-typed data-fetching hook is useful in dashboards, admin panels, and mobile web apps where many components depend on the same endpoints. It also pairs well with background sync mechanisms — you can combine the hook with service workers to enable offline-first behavior or background replenishment; see Using TypeScript with Service Workers: A Practical Guide.
Similarly, for compute-heavy pre-processing or to offload work, you can integrate typed fetching logic with web workers; the patterns in Using TypeScript with Web Workers: A Comprehensive Guide for Intermediate Developers translate well to typed message interfaces between main thread and worker.
If your stack targets Deno or server-side TypeScript, the same inference and union patterns apply — check Using TypeScript in Deno Projects: A Practical Guide for Intermediate Developers for deployment and type distribution specifics.
Conclusion & Next Steps
Typing a data-fetching hook well improves safety, DX, and maintainability. Start by defining the API surface, implement a minimal runtime-first version, then layer in types: generics, discriminated unions, and fetcher inference. Add caching and test type behavior across packages. Next steps: experiment with conditional types to derive response types from clients, add compile-time tests, and iterate on ergonomics.
Suggested follow-ups: read the articles linked throughout this post for bundler optimization, type puzzles, and monorepo strategies.
Enhanced FAQ
Q: When should I expose a generic type parameter to consumers versus inferring it?
A: Prefer inference: if your hook accepts a typed fetcher or a fetch function whose return type can be introspected, use conditional/infer types to automatically derive the response type. Expose a generic when the fetcher is untyped (for example, a raw fetch call where you call res.json
Q: How do discriminated unions help with UI rendering? A: Discriminated unions encode the status into the type system, forcing UI code to handle every state explicitly. Instead of checking data == null or status strings ad-hoc, the switch/case or exhaustive checks ensure you don't forget to handle the error or loading state.
Q: How can I type an axios-style fetcher that returns response.data?
A: Wrap the axios client with a typed helper that returns Promise
Q: What about runtime schemas and validation (zod/io-ts)? Should I type the hook with those? A: Yes — pairing runtime validation with static types is powerful. Use zod or io-ts to validate incoming JSON and then narrow the type at runtime. Your hook can accept a validator and return a typed result only if validation passes. This prevents runtime mismatches and complements TypeScript's compile-time guarantees.
Q: How do I test type-level behavior? A: Use libraries like tsd to write tests that assert certain call sites compile (or fail). Additionally, include a small examples folder with compiled samples as integration smoke tests in CI. For type distribution in larger repos, check Managing Types in a Monorepo with TypeScript.
Q: How should I handle stale data and refetch strategies without breaking type-safety?
A: Keep cached values strongly typed by setting and getting through typed APIs (e.g., setCache
Q: My hook is causing too many re-renders. How to optimize? A: Avoid storing transient values in state; use refs for intermediate mutable storage, batch state updates, and memoize fetchers with useCallback to avoid invalidating effect dependencies. Also reduce setState frequency by checking if the new value differs from the previous. For broader performance advice about TypeScript and runtime overhead, see Performance Considerations: Runtime Overhead of TypeScript (Minimal).
Q: Should I publish types separately or bundle them? A: Bundle declarations with your package by generating .d.ts files and pointing package.json's "types" field to them. This is the most user-friendly approach. If you maintain types centrally in a separate repo or monorepo, follow guidelines in Contributing to DefinitelyTyped: A Practical Guide for Intermediate Developers only if you intend to contribute to the community's type repo.
Q: How do I keep developer feedback fast while working on types? A: Use fast incremental TypeScript builds, editor integrations, and consider running a lightweight transpiler like esbuild/swc in watch mode during development. See Using esbuild or swc for Faster TypeScript Compilation and tune TypeScript flags with guidance from Advanced TypeScript Compiler Flags and Their Impact.
Q: Any tips for evolving the hook API without breaking consumers? A: Version the hook's package, provide migration guides, and prefer additive changes. Use deprecation warnings in runtime code and type-level deprecation comments. Keep small, focused functions so you can stabilize the public API surface. If you need to make a breaking change, use a new major version and provide codemods or migration docs.
If any of the sections above should include more concrete code examples tailored to your stack (axios, GraphQL, or a particular caching library), tell me your stack and I’ll extend the examples with that tooling in mind.
