How do you ensure type safety when working with external APIs?
4 minintermediatereactbest-practicesensuretypesafetyworking
Quick Answer
Key aspects: API Response Type Definitions; API Client with Type Safety; Runtime Validation with Zod; React Hooks with Type Safety; Error Handling with Discriminated Unions; Best Practices.
Detailed Answer
How do you ensure type safety when working with external APIs?
Answer:
1. API Response Type Definitions:
// Define API response types
interface User {
id: number;
name: string;
email: string;
avatar?: string;
createdAt: string;
updatedAt: string;
}
interface ApiResponse<T> {
data: T;
message: string;
status: 'success' | 'error';
timestamp: string;
}
interface PaginatedResponse<T> {
data: T[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
message: string;
status: 'success' | 'error';
}
// Specific API response types
type UserResponse = ApiResponse<User>;
type UsersListResponse = PaginatedResponse<User>;
2. API Client with Type Safety:
class ApiClient {
private baseURL: string;
private defaultHeaders: Record<string, string>;
constructor(baseURL: string) {
this.baseURL = baseURL;
this.defaultHeaders = {
'Content-Type': 'application/json',
};
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseURL}${endpoint}`;
const config: RequestInit = {
...options,
headers: {
...this.defaultHeaders,
...options.headers,
},
};
try {
const response = await fetch(url, config);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data as T;
} catch (error) {
console.error('API request failed:', error);
throw error;
}
}
// Typed API methods
async getUsers(page: number = 1, limit: number = 10): Promise<UsersListResponse> {
return this.request<UsersListResponse>(`/users?page=${page}&limit=${limit}`);
}
async getUser(id: number): Promise<UserResponse> {
return this.request<UserResponse>(`/users/${id}`);
}
async createUser(userData: Omit<User, 'id' | 'createdAt' | 'updatedAt'>): Promise<UserResponse> {
return this.request<UserResponse>('/users', {
method: 'POST',
body: JSON.stringify(userData),
});
}
async updateUser(id: number, userData: Partial<User>): Promise<UserResponse> {
return this.request<UserResponse>(`/users/${id}`, {
method: 'PUT',
body: JSON.stringify(userData),
});
}
}
3. Runtime Validation with Zod:
import { z } from 'zod';
// Define schemas for runtime validation
const UserSchema = z.object({
id: z.number(),
name: z.string().min(1),
email: z.string().email(),
avatar: z.string().url().optional(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
const ApiResponseSchema = z.object({
data: z.any(),
message: z.string(),
status: z.enum(['success', 'error']),
timestamp: z.string(),
});
// Type-safe API client with validation
class ValidatedApiClient extends ApiClient {
async getUsers(page: number = 1, limit: number = 10): Promise<UsersListResponse> {
const response = await this.request(`/users?page=${page}&limit=${limit}`);
// Runtime validation
const validatedResponse = ApiResponseSchema.parse(response);
// Validate the data array
if (Array.isArray(validatedResponse.data)) {
validatedResponse.data.forEach(user => UserSchema.parse(user));
}
return validatedResponse as UsersListResponse;
}
async getUser(id: number): Promise<UserResponse> {
const response = await this.request(`/users/${id}`);
// Validate the response structure
const validatedResponse = ApiResponseSchema.parse(response);
// Validate the user data
const validatedUser = UserSchema.parse(validatedResponse.data);
return {
...validatedResponse,
data: validatedUser,
} as UserResponse;
}
}
4. React Hooks with Type Safety:
// Custom hook for API calls
const useApi = <T>(apiCall: () => Promise<T>) => {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const result = await apiCall();
if (!cancelled) {
setData(result);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'An error occurred');
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
fetchData();
return () => {
cancelled = true;
};
}, [apiCall]);
return { data, loading, error };
};
// Usage with type safety
const UserProfile: React.FC<{ userId: number }> = ({ userId }) => {
const { data: userResponse, loading, error } = useApi<UserResponse>(
() => apiClient.getUser(userId)
);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!userResponse?.data) return <div>User not found</div>;
const user = userResponse.data; // TypeScript knows this is a User
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
{user.avatar && <img src={user.avatar} alt={user.name} />}
</div>
);
};
5. Error Handling with Discriminated Unions:
// Define error types
interface ApiError {
type: 'API_ERROR';
message: string;
statusCode: number;
details?: Record<string, any>;
}
interface ValidationError {
type: 'VALIDATION_ERROR';
message: string;
field: string;
value: any;
}
interface NetworkError {
type: 'NETWORK_ERROR';
message: string;
originalError: Error;
}
type ApiResult<T> =
| { success: true; data: T }
| { success: false; error: ApiError | ValidationError | NetworkError };
// Type-safe error handling
const safeApiCall = async <T>(
apiCall: () => Promise<T>
): Promise<ApiResult<T>> => {
try {
const data = await apiCall();
return { success: true, data };
} catch (error) {
if (error instanceof TypeError && error.message.includes('fetch')) {
return {
success: false,
error: {
type: 'NETWORK_ERROR',
message: 'Network request failed',
originalError: error,
},
};
}
if (error instanceof z.ZodError) {
return {
success: false,
error: {
type: 'VALIDATION_ERROR',
message: 'Data validation failed',
field: error.errors[0]?.path.join('.') || 'unknown',
value: error.errors[0]?.received,
},
};
}
return {
success: false,
error: {
type: 'API_ERROR',
message: error instanceof Error ? error.message : 'Unknown error',
statusCode: 500,
},
};
}
};
6. Best Practices:
- Always define interfaces for API responses
- Use runtime validation with libraries like Zod or Yup
- Implement proper error handling with discriminated unions
- Create type-safe API clients with generic methods
- Use custom hooks to encapsulate API logic
- Validate data at runtime to catch API changes
- Use TypeScript strict mode for better type checking
- Document API contracts and keep types in sync