DEV Community

loading...

Typesafe Mocking in TypeScript

Jon Rimmer
I ❤️ coding
・2 min read

Say I have an Angular service TodosApiService with the following shape:

@Injectable()
class TodosApiService {
  constructor(private http: HttpClient) {}
  getTodo(id: number): Observable<Todo> {}
  getTodos(): Observable<Todo[]> {}
  createTodo(todo: Todo): Observable<number> {}
  updateTodo(todo: Todo): Observable<void> {}
  deleteTodo(id: number): Observable<void> {}
  private checkCache(id: number): Todo | null
}

Now say I want to code a class MockTodosApiService that can be used in place of TodosApiService and which returns mock data instead of making actual network requests. This might be useful for situations like testing or developing components in isolation in a system like Storybook.

How can I ensure that my mock class provides the same interface as TodoApisService? I.e. that I am implementing all the public methods from the service, and that I am returning the correct types? And that if somebody comes along and adds a method bulkUpdateTodos() to TodosApiService that they get a compile error unless they also add it to MockTodosApiService?

One way would be to create a TypeScript interface that both TodosApiService and MockTodosApiService implement:

interface TodosApi {
  getTodo(id: number): Observable<Todo> {}
  getTodos(): Observable<Todo[]> {}
  createTodo(todo: Todo): Observable<number> {}
  updateTodo(todo: Todo): Observable<void> {}
  deleteTodo(id: number): Observable<void> {}
}

class TodosApiService implements TodosApi { }
class MockTodosApiService implements TodosApi { }

In languages like C# or Java that would be my only real option, but it's kind of a pain. I now have three places to make changes keep in sync with each other.

However, the dynamic nature of JS and TypeScript's expressiveness provide a different option. I can instead use mapped types to tell the compiler that I want my mock class to provide the same public interface as the "real" service. It's as simple as this:

class MockTodosApiService implements Pick<TodosApiService, keyof TodosApiService> { }

TypeScript will now complain if I implement MockTodosApiService in a way that is incompatible with the public interface of TodosApiService. However, it will not complain about my not replicating the constructor, or the private checkCache() method and http field.

This works because keyof returns the names of every public field and method of a type. We then use the Pick<> mapped type, which returns a subset of a type's properties. The result is a type that contains only those properties from TodosApiService that are returned by keyof TodosApiService. And by saying that MockTodosService implements that type, I get compile-time checking that I'm doing so correctly.

Discussion (0)