Question: Implement a Type-Safe Generic Data Fetcher
You are tasked with creating a type-safe generic function in TypeScript that fetches data from an API and handles different response types. The function should:
- Accept a URL and an optional configuration object for the fetch request.
- Use generics to define the expected response data type.
- Handle success and error cases with proper TypeScript types.
- Return a Promise that resolves to an object containing either the fetched data or an error message.
Requirements:
- Define an interface for the response structure.
- Use generics to make the function reusable for different data types.
- Handle HTTP errors (e.g., non-200 status codes) with a custom error type.
- Provide a usage example with two different data types (e.g., User and Product).
Bonus:
- Add type-safe handling for query parameters in the URL.
- Explain how your implementation ensures type safety.
Example API Endpoints:
-
https://api.example.com/users
(returns an array of users) -
https://api.example.com/products
(returns an array of products)
Sample Data Structures:
interface User {
id: number;
name: string;
email: string;
}
interface Product {
id: number;
name: string;
price: number;
}
Provide the complete TypeScript code, including types/interfaces, the fetch function, and example usage. Then, explain how your code ensures type safety and handles errors.
Expected Answer Outline:
The candidate should provide:
-
Interfaces/Types:
- Define a generic response interface (e.g.,
ApiResponse<T>
) to handle success and error cases. - Define a custom error type (e.g.,
ApiError
) for HTTP or network errors. - Define an interface for the fetch configuration, including query parameters.
- Define a generic response interface (e.g.,
-
Generic Fetch Function:
- Create a function like
fetchData<T>(url: string, config?: FetchConfig): Promise<ApiResponse<T>>
. - Use TypeScript generics to allow the function to work with any data type (e.g.,
User[]
orProduct[]
). - Implement error handling for network issues and non-200 status codes.
- Construct the URL with query parameters if provided.
- Create a function like
-
Example Usage:
- Show how to call
fetchData<User[]>
for the users endpoint andfetchData<Product[]>
for the products endpoint. - Demonstrate handling of success and error cases.
- Show how to call
-
Explanation:
- Describe how generics ensure the response data matches the expected type.
- Explain how the response interface provides type safety for success and error states.
- Discuss how query parameters are type-safely appended to the URL.
- Highlight error handling for robustness.
Sample Solution:
// Define custom error type
interface ApiError {
message: string;
status?: number;
}
// Define response structure
interface ApiResponse<T> {
data?: T;
error?: ApiError;
}
// Define fetch configuration with query parameters
interface FetchConfig {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
headers?: Record<string, string>;
queryParams?: Record<string, string | number>;
}
// Generic fetch function
async function fetchData<T>(url: string, config: FetchConfig = {}): Promise<ApiResponse<T>> {
try {
// Construct URL with query parameters
let finalUrl = url;
if (config.queryParams) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(config.queryParams)) {
params.append(key, value.toString());
}
finalUrl = `${url}?${params.toString()}`;
}
// Make fetch request
const response = await fetch(finalUrl, {
method: config.method || 'GET',
headers: config.headers,
});
// Check for HTTP errors
if (!response.ok) {
return {
error: {
message: `HTTP error: ${response.statusText}`,
status: response.status,
},
};
}
// Parse and return data
const data: T = await response.json();
return { data };
} catch (error) {
// Handle network or other errors
return {
error: {
message: error instanceof Error ? error.message : 'Unknown error occurred',
},
};
}
}
// Sample data interfaces
interface User {
id: number;
name: string;
email: string;
}
interface Product {
id: number;
name: string;
price: number;
}
// Example usage
async function main() {
// Fetch users with query parameters
const userResponse = await fetchData<User[]>(
'https://api.example.com/users',
{
queryParams: { limit: 10, page: 1 },
headers: { Authorization: 'Bearer token123' },
}
);
if (userResponse.data) {
console.log('Users:', userResponse.data);
} else {
console.error('User fetch error:', userResponse.error);
}
// Fetch products
const productResponse = await fetchData<Product[]>(
'https://api.example.com/products',
{
queryParams: { category: 'electronics' },
}
);
if (productResponse.data) {
console.log('Products:', productResponse.data);
} else {
console.error('Product fetch error:', productResponse.error);
}
}
main();
Explanation of Type Safety and Error Handling:
-
Generics:
- The
T
generic type ensures thedata
property inApiResponse<T>
matches the expected type (e.g.,User[]
orProduct[]
). This prevents type mismatches at compile time. - For example, calling
fetchData<User[]>
ensures thedata
property is typed asUser[]
, and TypeScript will flag any incorrect usage.
- The
-
Response Structure:
- The
ApiResponse<T>
interface uses a union-like structure (data?: T; error?: ApiError
) to ensure the response is either successful (data
) or failed (error
). This forces consumers to handle both cases explicitly.
- The
-
Query Parameters:
- The
FetchConfig
interface allows type-safe query parameters viaRecord<string, string | number>
. TheURLSearchParams
API ensures parameters are correctly formatted in the URL.
- The
-
Error Handling:
- HTTP errors (non-200 status codes) return an
ApiError
with the status code and message. - Network or parsing errors are caught in the
try-catch
block and return anApiError
with a descriptive message. - The
ApiError
interface ensures errors are structured and type-safe.
- HTTP errors (non-200 status codes) return an
-
Type Safety Benefits:
- TypeScript enforces that the consumer of
fetchData
knows the expected data type upfront, reducing runtime errors. - The optional
data
anderror
properties inApiResponse
ensure the consumer checks for errors before accessing data, preventing null/undefined errors. - The
FetchConfig
interface ensures only valid configuration properties are passed, and query parameters are safely serialized.
- TypeScript enforces that the consumer of
Bonus Considerations:
- The candidate could extend the function to support request bodies for POST/PUT requests by adding a
body
field toFetchConfig
with proper typing. - They could add type guards or utility functions to simplify response handling (e.g.,
isSuccessResponse<T>(response: ApiResponse<T>): response is { data: T }
).
This question tests:
- TypeScript fundamentals (interfaces, generics, union types).
- Practical API integration with
fetch
. - Error handling and type safety.
- Ability to explain design decisions.
It’s suitable for intermediate to senior developers and can be scaled down (e.g., remove query params) or up (e.g., add request body handling) based on the candidate’s experience level.
More Details
Check out the full code of this article on All About Typescript.
Get all articles related to system design:
Hashtag: #SystemDesignWithZeeshanAli
GitHub Repository: SystemDesignWithZeeshanAli
Top comments (0)