DEV Community

Cover image for Provide an API for your complex arrays
Taha Shashtari
Taha Shashtari

Posted on • Updated on • Originally published at tahazsh.com

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 }
]
Enter fullscreen mode Exit fullscreen mode

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

todos.find(todo => todo.id === 2)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode
// Get todos based on their completed state
const completedTodos = todos.filter(todo => todo.completed)
const notCompletedTodos = todos.filter(todo => !todo.completed)
Enter fullscreen mode Exit fullscreen mode
// Check if a todo with some id exists
const todoExists = todos.some(todo => todo.id === todoId)
Enter fullscreen mode Exit fullscreen mode
// 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)
}
Enter fullscreen mode Exit fullscreen mode
// Add a new todo to the todo list
const newTodo = { id: 4, title: 'Fourth Todo', completed: false }

todos.push(newTodo)
Enter fullscreen mode Exit fullscreen mode

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 todos.some(todo => todo.id === todoId)
  }

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

    this.#collection = this.#collection.filter(todo => todo.id !== todoId)
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (5)

Collapse
 
ant_f_dev profile image
Anthony Fung

I like this idea. Another benefit of this is that you could share the ToDo collection in various code modules by injecting that instance into the modules with a supporting framework.

I noticed that immutability is stated as a benefit. If I understand correctly, the collection itself is immutable, but the actual ToDo items are still mutable. Do you feel that there might be some benefit to returning deep copies of them, and only allowing updates via update methods in the array service/encapsulation?

Collapse
 
tahazsh profile image
Taha Shashtari • Edited

In real-word projects, I would also encapsulate the todo object, especially if it's mutable. In this example, I showed them as literal objects because I was focusing on the collection encapsulation.

As you might have noticed, returning the whole list of todos with get allTodos is deeply cloned—this means changing internal todos won't affect the actual ones. However, returning a todo with getTodoById returns the original one. This might be what you want (to use it as a repository). If the todos are encapsulated, then your code will be safe and clear. But if you don't want other parts in your code to modify internal todos, then you just need to replace all this.#collection with this.allTodos (except for allTodos) and then you will be sure that all the returned todos are immutable. The important thing here is to be consistent across your project.

Collapse
 
lionelrowe profile image
lionel-rowe

How would encapsulating the collection further make it immutable? Encapsulation has nothing to do with mutability. If you wanted it to be immutable, you'd expose methods that return a deep clone of the collection with some change.

It's also not true that the collection itself is immutable in this example — nothing is immutable. The collection encapsulates mutable state, so it's mutable.

Thread Thread
 
tahazsh profile image
Taha Shashtari • Edited

If you wanted it to be immutable, you'd expose methods that return a deep clone of the collection with some change.

That's what structuredClone(this.#collection) is doing in allTodos getter. I've mentioned this in the article.

It's also not true that the collection itself is immutable in this example — nothing is immutable.

  • The goal here is not to make the collection immutable. The goal is to limit the way we can change it. So in this example, you can only add or remove items to it through addTodo and removeTodo` methods.
  • Please note that this article is about collection encapsulation. So in this example, I'm showing how you can encapsulate the array itself, not its items. If you want to encapsulate its items, you need to create a Todo class and wrap each item with it, for example.

The collection encapsulates mutable state, so it's mutable.

Again, it's not about immutability, it's about controlling how it's mutated. Also note that it encapsulates the mutable state from its clients, not from itself.

Thread Thread
 
lionelrowe profile image
lionel-rowe

Re structuredClone, sorry, I'm dumb and can't read (not being sarcastic, I just often skip important details when I read stuff, mea culpa 😅)