REST API Client with Strong Type Validation
Build a typed API client in TypeScript with validation, filtering, and reliable response handling
Time to implement the project: ~ 18-25 hours
- TypeScript
- REST API Integration
- Strong Type Validation
- Async Data Handling
- Error Management
- Chakra UI
- Data Modeling
In this intermediate TS project, you will create a REST API client that fetches, validates, and displays external data through a structured interface. The main goal is to move beyond “it works” and build a client that treats incoming data as untrusted until it matches defined TypeScript types and validation rules. Users should be able to request records, browse result lists, inspect item details, and apply filters or sorting options without breaking UI consistency.
The application should include a typed service layer, reusable request utilities, and a presentation layer that clearly reflects loading, success, empty, and failure states. Chakra UI is a strong fit for this project because it provides clean layout primitives, accessible controls, and flexible feedback components without adding unnecessary visual complexity. The emphasis stays on type-safe data flow, readable architecture, and dependable state transitions.
Why This Project Matters for TypeScript Development
Many developers use TypeScript only for autocomplete and basic interfaces, but real value appears when type definitions shape the architecture of the application. This project teaches you to model API responses carefully, validate uncertain input, and keep unsafe data from leaking into UI components. That discipline is considered a practical skill in teams that work with third-party services, dashboards, admin panels, or internal tools.
You will also strengthen your understanding of separation of concerns. The request layer should fetch data, the validation layer should confirm its structure, and the UI layer should render only trusted values. This pattern makes larger TypeScript applications easier to debug, extend, and review.
Prerequisites and What You Should Already Know
This project assumes you already understand core TypeScript syntax and have some experience working with asynchronous JavaScript. You do not need a backend, but you do need confidence reading API responses, transforming arrays and objects, and structuring reusable modules.
- Solid understanding of TS types, interfaces, and union types
- Experience with
fetch, promises, and async/await - Ability to map, filter, and transform response data
- Basic knowledge of reusable component or module structure
- Familiarity with Chakra UI layout, form, and feedback components
- Comfort debugging network errors and inconsistent data shapes
Core Requirements for a Reliable API Client
A good API client should be predictable even when the API is not. That means the project must handle invalid responses, missing fields, and request failures without collapsing into fragile UI logic. The requirements below focus on trustworthy behavior and strong typing rather than visual detail.
| Requirement | Explanation |
| Typed request layer | API calls should be wrapped in reusable functions with explicit input and output expectations. |
| Response validation | Incoming data must be checked before it reaches the UI so incorrect structures do not silently pass through. |
| List and detail views | Users should be able to browse a collection of items and open a deeper view for a single record. |
| Loading, empty, and error states | The interface should explain what is happening instead of leaving users with blank or misleading screens. |
| Client-side filtering or sorting | Typed filtering logic proves that transformed data remains consistent after user interactions. |
| Reusable data models | Shared types should define records across services, validation helpers, and UI modules. |
| Readable feedback components | Chakra UI alerts, skeletons, and status blocks should communicate state clearly and accessibly. |
| Modular code organization | Services, types, validation helpers, and rendering logic should be separated to keep maintenance manageable. |
| Graceful fallback behavior | Missing optional fields should degrade safely instead of breaking layout or causing runtime crashes. |
Implementation Tips for Strong Type Safety
Start by choosing a single API resource and define its expected shape in TypeScript before writing fetch logic. Then create a validation step that confirms the response matches the structure you expect. Even basic guard functions can dramatically improve reliability when compared with trusting raw JSON. Keep the rendering layer simple: it should display validated data, not perform shape correction.
Chakra UI works especially well here because it lets you focus on interaction states - search controls, filters, alerts, loading placeholders, and cards - without building a custom design system from scratch. That keeps the project centered on data correctness, which is the real point of the exercise. When untrusted data is validated early, the rest of the codebase becomes easier to reason about.
- Define API entity types in one shared location and reuse them everywhere
- Create small validation helpers instead of mixing validation logic into UI code
- Use narrow types for request states such as loading, success, and error
- Normalize optional fields so components receive predictable values
- Keep filtering and sorting logic pure so it stays testable and safe
- Use Chakra UI skeletons and alerts to make async behavior obvious
- Log invalid responses during development to understand where data contracts fail
Common Mistakes When Building a REST API Client with Strong Type Validation
1. Trusting API JSON just because TypeScript has an interface
TypeScript types describe what your code expects, but they do not validate external data at runtime. A common mistake is fetching JSON, forcing it into an interface
with
as, and then passing it directly to UI components. The compiler becomes quiet, but the app can still break if the API returns missing fields, wrong types,
or a different structure.
Problematic approach:
interface User {
id: number;
name: string;
email: string;
status: "active" | "inactive";
}
async function getUsers(): Promise<User[]> {
const response = await fetch("/api/users");
const data = await response.json();
return data as User[];
}
This does not validate anything. If the API returns { id: "1", fullName: "Anna" }, TypeScript will still believe the result is a valid User.
Better approach with a type guard:
interface User {
id: number;
name: string;
email: string;
status: "active" | "inactive";
}
function isUser(value: unknown): value is User {
if (!value || typeof value !== "object") {
return false;
}
const user = value as Record<string, unknown>;
return (
typeof user.id === "number" &&
typeof user.name === "string" &&
typeof user.email === "string" &&
(user.status === "active" || user.status === "inactive")
);
}
function parseUsers(value: unknown): User[] {
if (!Array.isArray(value)) {
throw new Error("Expected users response to be an array.");
}
const validUsers = value.filter(isUser);
if (validUsers.length !== value.length) {
throw new Error("Users response contains invalid records.");
}
return validUsers;
}
Safer request function:
async function getUsers(): Promise<User[]> {
const response = await fetch("/api/users");
if (!response.ok) {
throw new Error("Users could not be loaded.");
}
const data: unknown = await response.json();
return parseUsers(data);
}
Pay attention to: Treat every API response as unknown until it passes validation. TypeScript protects your code, but validation protects
your app from real external data.
2. Returning raw Fetch or Axios responses directly to the UI
Components should not need to understand HTTP details such as status codes, headers, Axios response wrappers, or raw JSON parsing. A frequent mistake is calling
fetch or axios inside UI components and letting each component decide how to parse, validate, and handle errors. This creates duplicated
request logic and inconsistent behavior across the app.
Problematic approach:
function UsersPage() {
const [users, setUsers] = useState<User[]>([]);
const [error, setError] = useState("");
async function loadUsers() {
try {
const response = await axios.get("/api/users");
setUsers(response.data);
} catch {
setError("Something went wrong.");
}
}
return <UserList users={users} error={error} />;
}
This makes the component responsible for request execution, response extraction, error handling, and data trust. As the app grows, every page repeats slightly different API rules.
Better API client structure:
type ApiSuccess<T> = {
ok: true;
data: T;
};
type ApiFailure = {
ok: false;
message: string;
status?: number;
};
type ApiResult<T> = ApiSuccess<T> | ApiFailure;
async function request<T>(params: {
url: string;
validate: (value: unknown) => T;
}): Promise<ApiResult<T>> {
try {
const response = await fetch(params.url);
const rawData: unknown = await response.json();
if (!response.ok) {
return {
ok: false,
message: "Request failed.",
status: response.status
};
}
return {
ok: true,
data: params.validate(rawData)
};
} catch {
return {
ok: false,
message: "Network or validation error."
};
}
}
Service usage:
export function getUsers(): Promise<ApiResult<User[]>> {
return request({
url: "/api/users",
validate: parseUsers
});
}
Cleaner component:
async function loadUsers() {
const result = await getUsers();
if (!result.ok) {
setRequestState({
status: "error",
message: result.message
});
return;
}
setRequestState({
status: "success",
data: result.data
});
}
Pay attention to: Create a service layer that hides HTTP implementation details. Components should receive trusted data or a clear failure result, not raw transport objects.
3. Making the client too generic with string paths and any responses
A reusable API client should not mean “accept any path and return anything.” If every endpoint is typed as string and every response is any,
the client becomes a thin wrapper around fetch. It may reduce repeated syntax, but it does not provide strong type validation or reliable developer
feedback.
Problematic client:
async function apiClient(path: string, options?: RequestInit): Promise<any> {
const response = await fetch(path, options);
return response.json();
}
const user = await apiClient("/users/1");
console.log(user.fullname.toUpperCase());
TypeScript cannot warn you that the real field might be name instead of fullname, because the response is any.
Better endpoint schema:
interface Endpoints {
"/users": {
get: {
query: {
status?: "active" | "inactive";
};
response: User[];
};
};
"/users/:id": {
get: {
params: {
id: string;
};
response: User;
};
};
}
Typed client idea:
type ApiPath = keyof Endpoints;
async function get<Path extends ApiPath>(
path: Path,
options?: Endpoints[Path] extends { get: infer GetConfig }
? GetConfig extends { query?: infer Query }
? { query?: Query }
: never
: never
): Promise<
Endpoints[Path] extends { get: { response: infer Response } }
? Response
: never
> {
const response = await fetch(buildUrl(path, options?.query));
const data: unknown = await response.json();
return data as Endpoints[Path]["get"]["response"];
}
Typed usage:
const users = await get("/users", {
query: {
status: "active"
}
});
users.forEach((user) => {
console.log(user.name);
});
Pay attention to: A strong API client should know its allowed endpoints, request parameters, and response models. Avoid generic wrappers that erase the contract you are trying to protect.
4. Modeling async UI state with conflicting booleans
API clients are not only about request functions. The UI must also represent request status correctly. Beginners often create separate booleans such as
loading, error, empty, and success. These flags can accidentally conflict, producing impossible states like loading
and error at the same time.
Problematic state:
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [users, setUsers] = useState<User[]>([]);
async function loadUsers() {
setLoading(true);
try {
const data = await getUsers();
setUsers(data);
} catch {
setError("Users could not be loaded.");
}
setLoading(false);
}
This does not clearly describe the full request lifecycle. The app can forget to clear an old error, show stale data, or render a blank screen after an empty response.
Better discriminated union:
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "empty" }
| { status: "error"; message: string };
const [usersState, setUsersState] = useState<RequestState<User[]>>({
status: "idle"
});
async function loadUsers() {
setUsersState({ status: "loading" });
const result = await getUsers();
if (!result.ok) {
setUsersState({
status: "error",
message: result.message
});
return;
}
if (!result.data.length) {
setUsersState({ status: "empty" });
return;
}
setUsersState({
status: "success",
data: result.data
});
}
Chakra UI rendering:
if (usersState.status === "loading") {
return <Skeleton height="120px" />;
}
if (usersState.status === "error") {
return (
<Alert status="error">
<AlertIcon />
{usersState.message}
</Alert>
);
}
if (usersState.status === "empty") {
return <Text>No users match the current filters.</Text>;
}
if (usersState.status === "success") {
return <UserTable users={usersState.data} />;
}
return <Text>Choose filters and load users.</Text>;
Pay attention to: Use discriminated unions for request states. They make impossible UI states harder to create and make conditional rendering easier to understand.
5. Treating all API failures as the same error
Not every failed request means the same thing. A network failure, a 401 unauthorized response, a 404 missing resource, a 500 server error, and a validation failure should not produce the same internal state. When all errors become “Something went wrong,” the UI gives poor feedback and debugging becomes difficult.
Problematic error handling:
async function getUser(id: string): Promise<User> {
try {
const response = await fetch(`/api/users/${id}`);
return response.json();
} catch {
throw new Error("Something went wrong.");
}
}
This hides the actual reason for failure. The component cannot decide whether to show “log in again,” “record not found,” or “try later.”
Better error model:
type ApiError =
| { type: "network"; message: string }
| { type: "unauthorized"; message: string }
| { type: "not-found"; message: string }
| { type: "server"; message: string; status: number }
| { type: "validation"; message: string };
type ApiResult<T> =
| { ok: true; data: T }
| { ok: false; error: ApiError };
Typed error handling:
async function requestUser(id: string): Promise<ApiResult<User>> {
try {
const response = await fetch(`/api/users/${id}`);
if (response.status === 401) {
return {
ok: false,
error: {
type: "unauthorized",
message: "Please sign in again."
}
};
}
if (response.status === 404) {
return {
ok: false,
error: {
type: "not-found",
message: "User was not found."
}
};
}
if (!response.ok) {
return {
ok: false,
error: {
type: "server",
status: response.status,
message: "Server error. Try again later."
}
};
}
const rawData: unknown = await response.json();
return {
ok: true,
data: parseUser(rawData)
};
} catch {
return {
ok: false,
error: {
type: "network",
message: "Network error. Check your connection."
}
};
}
}
UI response:
function getErrorMessage(error: ApiError): string {
switch (error.type) {
case "unauthorized":
return "Your session expired. Please log in again.";
case "not-found":
return "This record no longer exists.";
case "validation":
return "The API returned data in an unexpected format.";
case "network":
return "Connection problem. Please retry.";
case "server":
return `Server error (${error.status}). Please try later.`;
}
}
Pay attention to: Model API errors as typed categories. This makes debugging easier and allows the UI to give users specific, useful recovery instructions.
By completing this project, you'll gain practical experience building a TS application that consumes REST APIs safely, validates uncertain data, and presents it through a clean, dependable interface. You will strengthen your ability to design reusable types, separate request and rendering concerns, and handle async UI states with confidence. This project prepares you for larger TypeScript systems where data contracts, maintainability, and predictable behavior matter every day.
Reference Implementations Worth Studying
Direct type-safe REST client reference:
AdisonCavani - TS REST API Client
This is the most directly aligned reference for the core idea of the project. It demonstrates a type-safe RESTful HTTP client in TypeScript where endpoints, request options, and response types are described through TypeScript types and interfaces. The example uses JSONPlaceholder to show common CRUD-style REST operations such as getting posts, getting comments, creating posts, updating records, patching records, and deleting data.
Pay particular attention to:
- How endpoint paths can be connected to specific request and response types.
- How a client function can infer the expected response type from the endpoint being called.
- How request options such as query parameters and request bodies can be modeled instead of passed as loose objects.
- How CRUD operations become more predictable when the endpoint schema is explicit.
- What you would add for a stronger UI project: runtime validation, request states, filters, and Chakra UI feedback components.
Use this repository as the closest technical baseline. It is especially useful for understanding the shape of a typed REST client before you connect it to a visual interface and runtime response validation.
Practical Axios wrapper reference:
RonasIT - Axios API Client
This implementation is useful because it shows how a reusable API service can be built around Axios for real application workflows. The package provides wrapper
utilities for REST API interaction, an ApiService instance, authentication configuration, request interceptors, token injection, refresh-token handling,
response interceptors, unauthorized-route behavior, and logout-related error handling.
When studying the code, focus on:
- How an API service instance centralizes the base URL and request behavior.
- How interceptors can attach tokens before requests without repeating code in every feature.
- How refresh-token logic can be handled as part of the client layer.
- How unauthorized responses can trigger a consistent application-level behavior.
- How this approach differs from a small typed fetch wrapper and why larger apps often need more infrastructure.
Use this repository as the practical application reference. It is less about validating one response shape and more about building a reusable HTTP layer that can support authentication, retries, refresh tokens, and consistent failure behavior across many screens.
Alternative OpenAPI code-generation reference:
acacode - Swagger TypeScript API
This repository is the strongest alternative direction because it generates TypeScript API clients from OpenAPI specifications. It supports OpenAPI 3.0 and 2.0, JSON and YAML specs, and can generate clients for Fetch or Axios. Instead of manually writing every endpoint type, this approach treats the API specification as the source of truth.
While reviewing this project, examine:
- How OpenAPI schemas can generate request and response types automatically.
- How generated clients reduce repetitive manual endpoint typing in large APIs.
- How Fetch and Axios generation options affect the shape of the client you use in the app.
- How CLI and library usage support different project workflows.
- Why generated types still need thoughtful UI states, error categories, and runtime validation strategy.
Use this implementation as the production-scale comparison point. For the main learning project, a hand-written typed client is valuable because it teaches the concepts. In larger teams, OpenAPI-based generation can keep frontend and backend contracts much closer together.