Should one use pipes or with?

There is no one size fits all … best is highly context sensitive.

Without a library the with/1 pattern demonstrated by @tme_317 is probably the best starting point.

def something(args) do
  with {:step1, {:ok, result1}} <- {:step1, task1(args)},
       {:step2, {:ok, result2}} <- {:step2, task2(result1)} do
  {:ok, result2}
else
  {_, error} -> error
end

Granted it isn’t particularly pretty but it gets the job done and there is some flexibility that goes beyond what the pipe can do.

Now I suspect that this has more to do with your own frustration - “why isn’t this already a solved problem within the language itself”.

Likely because this “problem” doesn’t actually come up all that often.

Erlang introduced {:ok, result}/{:error, reason} more than likely as a poor mans Either (or Result) type.

Given how optimized pattern matching is :ok/:error tuples are a good enough solution.

Putting my C hat on, I can easily imagine an Erlang programmer cringing at the thought of wasting precious function reductions passing an error value around through function calls just to comply with ROP. The attitude would be to drop everything and return the error value promptly - even if it meant a few more lines of code here and there, as long as it benefitted the runtime budget.

The Elixir pipe operator is merely a DevX function application feature that takes the place method chaining in OO languages and is almost as useful as function composition. The pipe operator never meant to take on the :ok/:error tuple issue.

That is really the domain of with/1. But in order to make it useful beyond just plain {:ok, result}/{:error, reason} values it is also more verbose than a pipe. And finally with/1 will quit at the first sign of trouble and is capable of soaking up all sorts of sins committed by the functions that it calls.

The same argument can be made against factoring a 1000 line function into multiple smaller functions. To me those smaller functions add value as long as they are well named and often they tend to make the code more declarative.

I hate trying to figure something like this out:

self.addEventListener('activate', event => {
  console.log('Activating new service worker...');

  const cacheWhitelist = [staticCacheName];

  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.map(cacheName => {
          if (cacheWhitelist.indexOf(cacheName) === -1) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

I find this much easier to reason about:

// Activate event
const cacheWhiteList = [staticCacheName]
const isObsoleteCache = name => cacheWhiteList.indexOf(name) === -1
const selectCachesToDelete = cacheNames => cacheNames.filter(isObsoleteCache)
const deleteNamedCache = name => self.caches.delete(name)
const deleteCaches = cacheNames => Promise.all(cacheNames.map(deleteNamedCache))

function activateListener (event) {
  console.log('Activating new service worker...')
  event.waitUntil(
    self.caches.keys().then(
      selectCachesToDelete
    ).then(
      deleteCaches
    )
  )
}

self.addEventListener('install', installListener)
self.addEventListener('fetch', fetchListener)
self.addEventListener('activate', activateListener)

… code for which some members in the JS community would probably lynch me for

So when you have a 10 function pipeline (or with/1) then maybe, just maybe that pipe is spanning multiple, distinct transformations that are just begging to be named for the benefit of future maintainers.

4 Likes