Alex Kit
Alex Kit

Alex Kit

Alot - turns your arrays into lazy and async streams

Alot - turns your arrays into lazy and async streams

Alex Kit's photo
Alex Kit
Β·Apr 5, 2022Β·

5 min read

Here I will go through the πŸ“¦ alot πŸ”— library, which I use to work with arrays in JavaScript/TypeScript. Though there are already dozens of utility libraries for arrays, there was always something I've missed there.

To position the alot library on a "map" and give you the orientation - I would say, it is somewhere between JavaScript native array methods and Rx, and in no way completely replaces both of them. Alot is highly inspired by LINQ methods from .NET.

TypeScript and installation

The library is implemented in TypeScript and has complete typings support out of the box, but can be used also in the plain js environment.

$ npm i alot

If you, the reader, still do not use TS on your daily dev basis, I would highly encourage you to switch to typescript. In later articles, I will show how I use TypeScript with no configuration and compilation hassle, and use it as it were a JavaScript (as it actually is, but with sweet additions.)

🌱 Laziness

Similar to rx cold observables, the chained methods won't be evaluated until you call subscribe method - the equivalents in alot library: toArray, toArrayAsync, first, firstAsync etc. But let's compare these two examples:

import alot from 'alot'

let arr1 = users
    .filter(user => user.score > 10)
    .map(user => user.name.toUpperCase())
    .slice(0, 5);

let arr2 = alot(users)
    .filter(user => user.score > 10)
    .map(user => user.name.toUpperCase())
    .take(5)
    .toArray()

Though the code here does the same things and looks similar, the flow is different. JavaScript methods do the following:

  1. filter all users by some score field
  2. map all previously filtered users to their uppercased names and create a new array of strings
  3. take(slice) the first 5 usernames.

As you may already have noticed, the caveat here is when the users array has lots of items - the engine goes through all the users to create a new filtered array, then goes over each item to pick the username and create the new array with names.

The example with alot library has the reversed direction flow. When the js engine evaluates filter, map, and take methods, it builds the query that gets executed when we call the toArray() method and the flow is:

  1. take 5 elements from the underlying map stream
  2. map users to their uppercased names from the underlying filter stream. As only 5 elements were requested it takes and maps also only 5 elements
  3. filter users by some score field. As only 5 elements were requested, it processes filtering only until 5 elements are matched

When I'm writing code I still think in forward-flow filter>map>take, but understanding how laziness works under the hood is beneficial.

So, in the best case, if the first 5 users have a score greater than 10, then the js engine visits only those 5 elements for filtering and mapping.

I have to mention, that it works great for this example. If we wouldn't have slice/take methods, then in alot example we would obviously map all filtered users and we wouldn't benefit from the laziness nature of alot lib. That's why I still often use js native array methods, and the alot API allows me quickly switch between different flows.

The laziness brings us to another feature, which was easy to implement - the asynchronous.

⛓️ Asynchronous

All methods in alot library have also there asynchronous variations, which accept async methods. From the previous example, what if we would want to load also user's comments along with uppercased username - for native js methods we have a huge headache, how to accomplish this. I have seen such wrong ❌ solution:

let promises = users
    .filter(user => user.score > 10)
    .map(async user => {
        return {
            username: user.name.toUpperCase(),
            comments: await Api.loadComments(user.id)
        };
    })
    .slice(0, 5);
let arr1 = await Promise.all(promises);

Wrong things about this could be:

  • here we load comments for all filtered users, even though we need only 5.
  • even, if we would need all users, not only 5, we make anyway all requests parallel - which means if we have an array with 1000 elements - we make 1000 requests simultaneously. (Though browsers and nodejs have max number for open connections, not all requests go immediately to the server, but it could cause timeout errors anyway)

With alot library it would be almost similar and βœ…

let arr2 = await alot(users)
    .filter(user => user.score > 10)
    .mapAsync(async user => {
        return {
            username: user.name.toUpperCase(),
            comments: await Api.loadComments(user.id)
        };
    })
    .takeAsync(5)
    .toArrayAsync({ threads: 2})

And the previous issues are solved:

  • due to the take method, we map only 5 elements, which means we make only 5 api requests to load the comments.
  • alot library can handle the async queue - in this example, we make 2 requests to the server at once (this number is just an example, you can set any number of simultaneous async tasks, default is 4)

So with little code change, we managed to create a lazy asynchronous stream

We can also make our filter async, in case we have to get the data for filtering also async.

let arr2 = await alot(users)
    .filterAsync(user => Api.isActive(user))
    .mapAsync(async user => {
        return {
            username: user.name.toUpperCase(),
            comments: await Api.loadComments(user.id)
        };
    })
    .takeAsync(5)
    .toArrayAsync({ threads: 2 })

Аttentive reader could notice, that such data loading is not optimal. Batch load by providing the array of userIds to the backend would be better. This example just demonstrates the async tasks. This is just a tool, how and when to use it depends on you.

βš™οΈ Additional array methods.

There are some other convenient methods to work with arrays. Check the Documentation

The methods I use often are:

  • groupBy(user => user.score) Returns stream of groups ({ key, values: T[] }[]) grouped by the same value, by the score in this example.

  • distinctBy(user => user.city) Returns stream of unique items by the field value. In this example: a user per city.

  • sortBy(user => user.age, 'asc') Returns sorted stream. Possible directions asc and desc. For better text sorting there is sortByLocalCompare method.

  • toMap(user => user.id, user => user.name) Returns an Map. From the example, the keys would be the ID of a user, and the value would be the name.

    I use this also to improve performance. Imagine, you need to query the name of a user by id often. Then instead of users.find(x =>x.id === id)?.name I create a Map of id:name and then simply get the name: map.get(id)

    toDictionary method returns object instead of Map, just in case it is more convinient for you


Happy coding

🏁

Did you find this article valuable?

Support Alex Kit by becoming a sponsor. Any amount is appreciated!

Learn more about Hashnode Sponsors
Β 
Share this