March 9, 2023

Provide an API for your complex arrays

We deal with arrays all the time. Some of them are simple, like an array of strings or numbers, and some are more complex, like an array of objects.

Arrays can also be considered complex based on their use. If accessing some element from an array takes more work than a direct access, then I would consider it complex.

Here’s an example:

const todos = [
  { id: 1, title: 'First Todo', completed: false },
  { id: 2, title: 'Second Todo', completed: true },
  { id: 3, title: 'Third Todo', completed: false }
]

If I want to access the todo with an id of 2, I need to write this:

todos.find((todo) => todo.id === 2)

This is not a direct access. I had to make some check based on some property.

If that’s all what this array is going to be used for, then I would not worry about improving it. But in real-world projects, we do more stuff with these arrays—like deleting, updating, or checking if it contains a specific value.

Before I show you what improvements you can do to these arrays, let me show you why you would need these improvements in the first place.

Array operation examples

If I’m building a todo app, then I would need to write code that deals with the todos array like this.

// Get a todo by id
const todo = todos.find((todo) => todo.id === todoId)
if (!todo) {
  // this todo does not exist
  // maybe throw an error about that
}
// Get todos based on their completed state
const completedTodos = todos.filter((todo) => todo.completed)
const notCompletedTodos = todos.filter((todo) => !todo.completed)
// Check if a todo with some id exists
const todoExists = todos.some((todo) => todo.id === todoId)
// Delete a todo using its id
const todoExists = todos.some((todo) => todo.id === todoId)
if (todoExists) {
  todos = todos.filter((todo) => todo.id !== todoId)
}

// Alternative way
const todoIndex = todos.findIndex((todo) => todo.id === todoId)
if (todoIndex !== -1) {
  todos.splice(todoIndex, 1)
}
// Add a new todo to the todo list
const newTodo = { id: 4, title: 'Fourth Todo', completed: false }

todos.push(newTodo)

These are just a few examples of the operations you would write for an array like this.

It might seem ok to just write them like that. But the issue appears when you repeat each of these operations throughout your codebase—and in most cases you would have more operations and they would be more complex.

More issues would appear when you decide to update how your operations should work; in this case, you would need to update all the places that use that array, instead of updating it in a single place—and it’s more likely to have bugs when you update the same thing in multiple places.

Also, with this approach, there’s no clear way to test the operations on this array.

Another big issue is mutability. In this example, the array is mutable, which means any part of the app can change it arbitrarily—which is a source of a lot of potential bugs.

Now, you know why handling your array’s operations like this is bad. What’s the solution? The answer is: encapsulate your arrays.

Array encapsulation

The title of this article is “Provide an API for your complex arrays”, which is the same as saying encapsulate your arrays.

To encapsulate your array, you need to prevent direct changes to it by hiding it and exposing an API to operate on it.

The best way to encapsulate it is to wrap it with an object and add all the related operations to that object.

class TodoCollection {
  #collection = []

  constructor(todos) {
    this.#collection = todos
  }

  get allTodos() {
    return structuredClone(this.#collection)
  }

  get completedTodos() {
    return this.#collection.filter((todo) => todo.completed)
  }

  getTodoById(todoId) {
    return this.#collection.find((todo) => todo.id === todoId)
  }

  addTodo(todo) {
    this.#collection.push(todo)
  }

  contains(todoId) {
    return this.#collection.some((todo) => todo.id === todoId)
  }

  removeTodo(todoId) {
    if (!this.contains(todoId)) return

    this.#collection = this.#collection.filter((todo) => todo.id !== todoId)
  }
}

Now any time you want to use the todos array, you would wrap it with that object like this:

const todos = new TodoCollection(todosArray)

// Get a todo
const todo = todos.getTodoById(2)

// Remove a todo
todos.removeTodo(2)

With this approach, I have a well-defined API that deals with the array explicitly.

Not only that, but it’s now impossible to modify the array unintentionally. The only way to modify it is through its addTodo and removeTodo methods. The reason you can’t modify it with todos.allTodos.push(newTodo) is because todos.allTodos returns a clone of the array; so changing it doesn’t change the original array.

Updating the code is easy now. If, for example, you want your array to throw an error if a todo doesn’t exist, you just need to update getTodoById method’s code.

Another great benefit is it’s now easy to test—you now have the TodoCollection class to test.

Stay up-to-date on the latest projects and articles from me