Hone logo
Hone
Problems

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:

  1. Define Data Models: Create TypeScript interfaces for the User object and any other relevant data structures (e.g., for creating or updating users).
  2. API Client Class: Implement a class, let's call it UserServiceClient, that encapsulates the API interaction logic.
  3. CRUD Operations: Implement methods within UserServiceClient for:
    • 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.
  4. Type Safety:
    • All request bodies (for createUser and updateUser) 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[] for getUsers, User for getUserById, User for createUser and updateUser, void or a success indicator for deleteUser).
    • Handle potential API errors gracefully, perhaps by returning a specific error type or throwing custom exceptions.
  5. Base URL Configuration: The UserServiceClient should accept a base URL for the API during instantiation.
  6. 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 of User objects.
  • When calling getUserById('some-id'), the client should return a single User object or undefined if the user doesn't exist.
  • When calling createUser(newUser), the client should return the newly created User object with an assigned ID.
  • When calling updateUser('some-id', updatedData), the client should return the updated User object.
  • When calling deleteUser('some-id'), the client should successfully remove the user.
  • If an invalid userId is provided where an ID is expected, TypeScript should flag this at compile time if the type is incorrect.
  • If incorrect data is passed to createUser or updateUser (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/await for all API operations, returning Promises.
  • No external HTTP client libraries (like axios or node-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., age might be optional for creation but required for updates, or vice-versa).
  • For the simulation, you can maintain an in-memory Map or array within your test setup or within a mock implementation of the client's "backend" logic.
  • Success is defined by having a UserServiceClient class where all methods are correctly typed, and attempting to use the client with incorrect data types results in TypeScript compilation errors.
Loading editor...
typescript