Final Refactoring of the Task Client: Improving API Architecture
Master professional refactoring for your API client. Learn how to centralize logic, eliminate DRY violations, and finalize your TypeScript interface layer.

Previously in this course, we explored inferring types in conditionals. Today, we’re bringing everything together to perform the final refactoring of our Task API client.
By this stage, our client works, but it’s likely grown organically, with duplicated fetch logic and inconsistent type usage. This lesson focuses on centralizing our configuration, refining our generic request wrappers, and establishing a professional-grade interface layer.
Refactoring for DRY (Don't Repeat Yourself)
In Building the Task API Client: Setup and Architecture, we initialized our client. Since then, we’ve added complex features, but often at the cost of repetition. Our goal now is to move all shared concerns—base URLs, headers, and error handling—into a single, robust configuration object.
Instead of defining headers in every method, let's create a centralized client class.
TYPESCRIPT// src/api/ApiClient.ts export class ApiClient { private baseUrl: string; private defaultHeaders: HeadersInit; constructor(baseUrl: string) { this.baseUrl = baseUrl; this.defaultHeaders = { CE9178">'Content-Type': CE9178">'application/json', CE9178">'Authorization': CE9178">`Bearer ${process.env.API_TOKEN}` }; } protected async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> { const response = await fetch(CE9178">`${this.baseUrl}${endpoint}`, { ...options, headers: { ...this.defaultHeaders, ...options.headers } }); if (!response.ok) { throw new Error(CE9178">`API Error: ${response.statusText}`); } return response.json() as Promise<T>; } }
By encapsulating the fetch logic here, we ensure that if we need to implement advanced Error Handling Best Practices: Clean API Design and Debugging later, we only change it in one place.
Improving Generic Usage

We have been using generics for our API responses, but we can make them even safer by constraining them to specific shapes. Currently, our request method accepts any type T. We should restrict this to ensure the API responses always adhere to our established interfaces.
TYPESCRIPT// Applying a constraint to our generic protected async request<T extends object>( endpoint: string, options: RequestInit = {} ): Promise<T> { // ... implementation }
By adding extends object, we prevent passing primitives like string or number as the expected response type, which is never valid for a JSON API response. This small change enforces better architectural discipline.
Finalizing the Interface Layer
A clean API client should hide the underlying HTTP implementation from the rest of the application. We achieve this by defining clear methods that represent our domain actions rather than HTTP verbs.
TYPESCRIPT// src/api/TaskClient.ts import { ApiClient } from CE9178">'./ApiClient'; import { Task } from CE9178">'../types'; export class TaskClient extends ApiClient { async getTask(id: string): Promise<Task> { return this.request<Task>(CE9178">`/tasks/${id}`); } async updateTask(id: string, data: Partial<Task>): Promise<Task> { return this.request<Task>(CE9178">`/tasks/${id}`, { method: CE9178">'PATCH', body: JSON.stringify(data) }); } }
This structure is highly maintainable. The rest of your application interacts with TaskClient methods, completely unaware that fetch or JSON.stringify is happening under the hood.
Hands-on Exercise
- Open your current
TaskClientimplementation. - Identify three places where you manually set the
Content-Typeheader or repeatJSON.stringify. - Create a
BaseApiClientclass as shown above and move that logic into the constructor or therequestmethod. - Refactor
TaskClientto extendBaseApiClientand verify that all tests still pass.
Common Pitfalls
- Over-engineering: Don't build a massive abstraction layer before you need it. If you only have two endpoints, a simple function is better than a complex class hierarchy.
- Ignoring Runtime Types: TypeScript types disappear at runtime. Even with clean generic code, always validate that the JSON returned from the API matches your interface (consider a validation library like Zod for production).
- Hardcoding URLs: Avoid hardcoding base URLs inside your service classes. Inject them via the constructor or environment variables to facilitate testing.
FAQ
Q: Should I always use a class for API clients? A: Not necessarily. A factory function that returns an object with methods can be just as effective and sometimes easier to mock in tests. Choose the pattern that best fits your team's familiarity.
Q: Why use protected for the request method?
A: It hides the low-level request method from the consumers of your client, exposing only the domain-specific methods like getTask or updateTask.
Recap

We have successfully refactored our task client to be DRY, generic-safe, and cleanly separated from the rest of the app. By moving from scattered fetch calls to a centralized BaseApiClient, we've ensured our infrastructure can evolve without breaking our business logic.
Up next: Testing Type-Safe Code. We will learn how to verify that our types behave exactly as expected during the build process.
Work with me

Laravel REST API Development
Clean, secure, well-documented Laravel REST APIs — the backend engine for your app, mobile client, or SaaS. Built by an API specialist.

Next.js Full-Stack Web App Development
A fast, SEO-ready full-stack web app built with Next.js 16 — from idea to deployed product, by an engineer who ships to production.
