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