Build a Type-Safe TypeScript API Client for a Fictional User Service
This challenge focuses on building a robust and type-safe API client in TypeScript for a hypothetical user management service. By creating such a client, you'll ensure that interactions with the API are predictable, prevent runtime errors due to incorrect data types, and improve the overall developer experience.
Problem Description
You need to create a TypeScript class that acts as a client for a RESTful API that manages user data. This API provides endpoints for retrieving, creating, updating, and deleting users. The primary goal is to make this client "type-safe," meaning that all data passed to and received from the API will be validated at compile time by TypeScript.
Key Requirements:
- Define Data Models: Create TypeScript interfaces for the
Userobject and any other relevant data structures (e.g., for creating or updating users). - API Client Class: Implement a class, let's call it
UserServiceClient, that encapsulates the API interaction logic. - CRUD Operations: Implement methods within
UserServiceClientfor:getUsers(): Fetches all users.getUserById(userId: string): Fetches a single user by their ID.createUser(userData: CreateUserDto): Creates a new user.updateUser(userId: string, userData: UpdateUserDto): Updates an existing user.deleteUser(userId: string): Deletes a user.
- Type Safety:
- All request bodies (for
createUserandupdateUser) must conform to their respective DTO types (CreateUserDto,UpdateUserDto). - The return types of all methods must accurately reflect the expected API responses (e.g.,
User[]forgetUsers,UserforgetUserById,UserforcreateUserandupdateUser,voidor a success indicator fordeleteUser). - Handle potential API errors gracefully, perhaps by returning a specific error type or throwing custom exceptions.
- All request bodies (for
- Base URL Configuration: The
UserServiceClientshould accept a base URL for the API during instantiation. - Simulated API: For testing purposes, you will not make actual HTTP requests. Instead, you will simulate the API responses. This can be done using a simple in-memory data store within your test setup or by returning mock data directly from your client methods in a controlled environment.
Expected Behavior:
- When calling
getUsers(), the client should return an array ofUserobjects. - When calling
getUserById('some-id'), the client should return a singleUserobject orundefinedif the user doesn't exist. - When calling
createUser(newUser), the client should return the newly createdUserobject with an assigned ID. - When calling
updateUser('some-id', updatedData), the client should return the updatedUserobject. - When calling
deleteUser('some-id'), the client should successfully remove the user. - If an invalid
userIdis provided where an ID is expected, TypeScript should flag this at compile time if the type is incorrect. - If incorrect data is passed to
createUserorupdateUser(e.g., missing a required field), TypeScript should catch this during development.
Edge Cases to Consider:
- User not found when fetching by ID.
- Invalid input data formats for creation/update (though TypeScript should prevent most of this).
- API returning errors (e.g., 404, 500). You should define how these are handled.
Examples
Example 1: Creating a User
// Assume User and CreateUserDto are defined appropriately
interface User {
id: string;
name: string;
email: string;
age?: number;
}
interface CreateUserDto {
name: string;
email: string;
age?: number;
}
// In your simulated API/test setup:
const mockDatabase = new Map<string, User>();
let nextId = 1;
const simulatedCreateUserApi = (userData: CreateUserDto): User => {
const newUser: User = {
id: `user-${nextId++}`,
...userData,
};
mockDatabase.set(newUser.id, newUser);
return newUser;
};
// In your client implementation:
class UserServiceClient {
private baseUrl: string;
private users: Map<string, User> = new Map(); // For simulation
private nextId: number = 1; // For simulation
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
// In a real scenario, this would be where you'd configure an HTTP client like Axios
}
async createUser(userData: CreateUserDto): Promise<User> {
// Simulate API call
const newUser = {
id: `user-${this.nextId++}`,
...userData,
};
this.users.set(newUser.id, newUser);
console.log(`Simulating API POST ${this.baseUrl}/users with body:`, userData);
return Promise.resolve(newUser);
}
// ... other methods
}
// Usage:
const client = new UserServiceClient("http://api.example.com");
const newUserPayload: CreateUserDto = {
name: "Alice Smith",
email: "alice.smith@example.com",
age: 30,
};
// TypeScript will ensure newUserPayload matches CreateUserDto
// If we tried: const badPayload = { name: "Bob" }; // TypeScript error!
const createdUser = await client.createUser(newUserPayload);
console.log("Created User:", createdUser);
/*
Expected Output (or similar, depending on simulation):
Simulating API POST http://api.example.com/users with body: { name: 'Alice Smith', email: 'alice.smith@example.com', age: 30 }
Created User: { id: 'user-1', name: 'Alice Smith', email: 'alice.smith@example.com', age: 30 }
*/
Example 2: Getting a User by ID
// Assume User interface is defined as above
// In your simulated API/test setup:
// (Continuing from Example 1)
const mockDatabase = new Map<string, User>();
mockDatabase.set("user-1", { id: "user-1", name: "Alice Smith", email: "alice.smith@example.com", age: 30 });
mockDatabase.set("user-2", { id: "user-2", name: "Bob Johnson", email: "bob.j@example.com" });
// In your client implementation:
class UserServiceClient {
// ... (constructor and createUser as above)
async getUserById(userId: string): Promise<User | undefined> {
// Simulate API call
console.log(`Simulating API GET ${this.baseUrl}/users/${userId}`);
const user = this.users.get(userId); // Using simulated internal state
return Promise.resolve(user);
}
// ... other methods
}
// Usage:
const client = new UserServiceClient("http://api.example.com");
const user1 = await client.getUserById("user-1");
console.log("User 1:", user1);
const nonExistentUser = await client.getUserById("user-99");
console.log("Non-existent User:", nonExistentUser);
/*
Expected Output:
Simulating API GET http://api.example.com/users/user-1
User 1: { id: 'user-1', name: 'Alice Smith', email: 'alice.smith@example.com', age: 30 }
Simulating API GET http://api.example.com/users/user-99
Non-existent User: undefined
*/
Example 3: Type Safety with Invalid Data
// Assume User and CreateUserDto are defined as above
// In your client implementation:
class UserServiceClient {
// ... (constructor)
async createUser(userData: CreateUserDto): Promise<User> {
// ... (simulation logic)
console.log(`Simulating API POST ${this.baseUrl}/users with body:`, userData);
const newUser = { id: `user-${Math.random().toString(36).substring(7)}`, ...userData };
return Promise.resolve(newUser);
}
// ...
}
// Usage:
const client = new UserServiceClient("http://api.example.com");
// THIS WILL CAUSE A COMPILE-TIME ERROR because 'email' is missing
// const invalidUserData = {
// name: "Charlie Brown",
// age: 10
// };
// await client.createUser(invalidUserData);
// This is valid:
const validUserData: CreateUserDto = {
name: "Charlie Brown",
email: "charlie@example.com",
age: 10
};
const createdCharlie = await client.createUser(validUserData);
console.log("Created Charlie:", createdCharlie);
/*
Expected Output (if validUserData is used):
Simulating API POST http://api.example.com/users with body: { name: 'Charlie Brown', email: 'charlie@example.com', age: 10 }
Created Charlie: { id: '...', name: 'Charlie Brown', email: 'charlie@example.com', age: 10 }
*/
Constraints
- The API client should be implemented as a single TypeScript class.
- You must use
async/awaitfor all API operations, returningPromises. - No external HTTP client libraries (like
axiosornode-fetch) are required for this challenge; you will simulate the responses. - The solution should be written entirely in TypeScript.
- Focus on compile-time type safety. Runtime type checking within the client methods is not the primary goal, but rather leveraging TypeScript's static analysis.
Notes
- Think about how you would represent different API responses and errors using TypeScript types. For instance, what should the return type be if an API call fails with a 404 Not Found?
- Consider defining separate DTOs (Data Transfer Objects) for creating and updating resources, as they might have different fields (e.g.,
agemight be optional for creation but required for updates, or vice-versa). - For the simulation, you can maintain an in-memory
Mapor array within your test setup or within a mock implementation of the client's "backend" logic. - Success is defined by having a
UserServiceClientclass where all methods are correctly typed, and attempting to use the client with incorrect data types results in TypeScript compilation errors.