DEV Community

Samuel Rouse
Samuel Rouse

Posted on • Updated on

Promises: Five Tips to Work Smarter

Promises are powerful tools in our increasingly asynchronous world. They help us leave behind the pain of nested callbacks and clarify program flow. However, there are still plenty of misunderstandings about promises that prevent developers from making full use of them.

Here are a few tips that may improve the quality and readability of code in your project.

1. You Don't Need* That Anonymous Function

Examples of promises usually include anonymous arrow functions inside every .then() but you often don't need them. Removing them helps show the readability and flow control promises can provide.

getZero()
  .then((number) => addOne(number))
  .then((number) => addTwo(number))
  .then((number) => addThree(number))
  .then((number) => console.log(number));

// Let's clean it up. Now we can read it like a sentence.
getZero()
  .then(addOne)
  .then(addTwo)
  .then(addThree)
  .then(console.log);
Enter fullscreen mode Exit fullscreen mode

Here is actual code from a project I work on. These seven lines are the entire flow of a program. Without reading each function we get a very clear idea of what is happening. (Function names have been improved for clarity.) No arrow function or extra noise gets in the way of expressing the behavior.

portUsed.check(port)
  .then(rejectIfUsed)
  .then(loadEnvironmentConfig)
  .then(startMonitor)
  .then(startServer)
  .then(logServerDetails)
  .catch(reportError);
Enter fullscreen mode Exit fullscreen mode

* Sometimes You Need That Anonymous Function

Of course, there are exceptions. Anonymous functions can be useful or necessary when accessing values outside a promise chain or preserving method context. There are ways around these issues, but they are not always better or easier. Let's take a look.

const state = {
  color: 'blue',
  setColor(newColor) {
    this.color = newColor;
  },
};

// We need some information not in the promise chain
getSystemData()
  .then((config) => showColorConfig(state.color, config));
Enter fullscreen mode Exit fullscreen mode

You can use .bind() or techniques like partial application and currying (not covered here) to avoid the anonymous function, but developers with less experience in functional programming may find these harder to follow than an arrow function.

// Bind example to access state outside the chain
getSystemData()
  .then(showColorConfig.bind(null, state.color));
Enter fullscreen mode Exit fullscreen mode

Similarly, accessing a method on an object or class instance can be easier with an anonymous function. There are alternatives here, too.

// Regular method on an instance or object.
state.setColor('yellow');

// BAD, "state" context gets lost when setColor is called.
// "this" inside setColor is not what we expect.
getColor()
  .then(state.setColor);

// Good, preserve method context
getColor()
  .then((color) => state.setColor(color));

// Also OK, bind method context
getColor()
  .then(state.setColor.bind(state));
Enter fullscreen mode Exit fullscreen mode

These choices often come down to conventions of a project and the comfort of the developers.

2. Promise Everything

One of the biggest features of Promises is the continuation of the promise chain. Any non-promise function response is wrapped in a resolved promise, so promise chains automatically allow any function in the chain to be sync or async.

All you have to do to get this benefit is start with a Promise. Using Promise.resolve() is a simple way to reliably open a promise chain. After that, you don't need to know if something is async; it just works.

Promise.resolve()
  .then(getFirstValue)
  .then(addOne)
  .then(addRandomValue)
  .then(saveValueToServer)
  .then(displayValue)
  .catch(displayError);
Enter fullscreen mode Exit fullscreen mode

From a glance you cannot tell which of these functions are async, and at this high level it doesn't matter. You can still read the flow of the code, and it just runs. As a project grows and changes, it is trivial to change functions from sync to async if they already live in a promise chain.

Starting a chain with Promise.resolve() means even the very first action in your promise chain – the one almost guaranteed to be async – can be swapped out with a synchronous version. This can be a big advantage for writing tests.

We can replace any async method with a synchronous mockup to check if the rest of the program behaves as expected. We can mock up resolved or rejected promises for different test scenarios. And, as our code evolves, we can swap each function between sync and async without touching this code.

3. Try/Catch For Free

Promises capture thrown errors as a rejected promise. This can replace most try/catch logic. Every function in a promise chain gets this wrapping, so we get error handling no matter which function fails.

Async/await requires us to add error handling anywhere we need it, and we can still see uncaught errors if we assume a function does not fail. This is a bigger risk if updates introduce errors later in the project.

There's a lot of details and options in error handling, so let's break it down a bit.

The Basics

When the code is pretty simple, we can just wrap everything in a try/catch. At this level, the difference between try/catch and promises is minor.

// Everything in try/catch for simplicity
try {
  const settings = await getUserSettings();
  const data = JSON.parse(settings);
  displayUserSettings(data);
} catch(e) {
  displayErrorPage(e);
}

Promise.resolve()
  .then(getUserSettings)
  .then(JSON.parse)
  .then(displayUserSettings)
  .catch(displayErrorPage);
Enter fullscreen mode Exit fullscreen mode

A small note: In our example above, unlike state.setColor in a previous example, JSON.parse doesn't require a context. This makes it safe to pass directly rather than wrap it in an anonymous function.

Recoverable Errors

But what if we have more requirements? What if the program can recover from certain errors? Maybe we want to display default user settings if getUserSettings fails.

With try/catch blocks, we must anticipate each possible failure to handle it.

let data;
try {
  const settings = await getUserSettings();
  // JSON.parse can throw an error, too.
  data = JSON.parse(settings);
} catch {
  data = useDefaultSettings();
}

try {
  displayUserSettings(data);
} catch(e) {
  displayErrorPage(e);
}
Enter fullscreen mode Exit fullscreen mode

Multiplying Errors

If functions in our "recoverable" catch block can error, we need to nest try/catch blocks. For this we can make different functions to add layers of error handling without deep indentation making things harder to follow.

// Separate logic to avoid nesting
const tryUserSettings = async () => {
  try {
    const settings = await getUserSettings();
    return JSON.parse(settings);
  } catch {
    return useDefaultSettings();
  }
};

try {
  // Handling more possibilities.
  const data = await tryUserSettings();
  displayUserSettings(data);
} catch(e) {
  displayErrorPage(e);
}
Enter fullscreen mode Exit fullscreen mode

Now we have error handling around everything, but it's harder to follow the program flow. But we can do better. Because await works on any promise, we can blend together promise chains and async/await.

// Simple await.catch() pattern
const data = await mainAction().catch(backupAction);
Enter fullscreen mode Exit fullscreen mode

This style can help group certain actions and error handlers more neatly than multiple try/catch blocks while keeping all our logic together.

// A bit of both - await a promise chain
// All of "get data" is now one block
//  and we catch errors from useDefaultSettings

try {
  const data = await getUserSettings()
    .then(JSON.parse)
    .catch(useDefaultSettings);
  displayUserSettings(data);
} catch(e) {
  displayErrorPage(e);
}
Enter fullscreen mode Exit fullscreen mode

It took a bit, but we've handled all the errors, even from useDefaultSettings.

The Promise Way

Let's implement the same logic in a promise chain. With promises, we can use .catch() along the way to support fallbacks or generic settings when the intended behavior fails. We also benefit from Tip #2 in that we don't need to know which functions are async.

Promise.resolve()
  .then(getUserSettings)
  .then(JSON.parse)
  .catch(useDefaultSettings)
  .then(displayUserSettings)
  .catch(displayErrorPage);
Enter fullscreen mode Exit fullscreen mode

Promises can handle the errors with less code, fewer assumptions about the functions, and a clearer program flow.

4. Avoid Accidental Serialization

async/await is great for writing code that looks synchronous, but isn't. In some circumstances, that synchronous-looking code can cause delays. Using pure promises or a combination of Promise methods and async/await can keep your code running at peak performance. Here are some code examples of the problem and some alternatives.

// Accidental Serialization
const getProductDetails = async () => {
  // Await stops the code, so getProductList
  //  won't start until getUserSettings is done.
  const user = await getUserSettings();
  const products = await getProductList();

  // Did products need to wait for user?
  console.log(user, products);
};

// Prevent Serialization
const getProductDetails = async () => {
  // These variables are promises.
  // Both requests start immediately.
  const user = getUserSettings();
  const products = getProductList();

  // No waiting, but we must remember they are promises.
  console.log(await user, await products);
};

// Hybrid solution
const getProductDetails = async () => {
  // No serialization thanks to Promise.all.
  // await gets us out of the promise chain.
  const [user, products] = await Promise.all([
    getUserSettings(),
    getProductList(),
  ]);
  console.log(user, products);
};

// Only Promises - No async needed
const getProductDetails = () => {
  Promise.all([
    getUserSettings(),
    getProductList(),
  ]).then(([user, products]) => {
    console.log(user, products);
  });
};
Enter fullscreen mode Exit fullscreen mode

These situations often come up in a project when adding features or complexity. Whether you are using promises alone or blending them with async/await, promises are one of the best options for ensuring you don't make your code – or your user – wait longer than needed.

5. Promises Aren't a Panacea

While promises do a great job of encapsulating complex details so you can read a high-level program flow, there are places where trying to fit everything into promises can get messy. Complex or repeating behaviors like request retries and polling may be easier to read and reason about outside of a promise chain.

I find them best when you start thinking from the high-level flow of a project. You can use a simple promise chain to sketch out what you want your code to do...

const eureka = () => Promise.resolve()
  .then(connectToHomeServer)
  .then(loadSmartDeviceList)
  .then(selectOnlyLights)
  .then(turnOnDevices);
Enter fullscreen mode Exit fullscreen mode

...and then make it happen.

Discussion (1)

Collapse
zyabxwcd profile image
Akash

Neat. I like this type of article rather than usual tutorials on what a promise does and the different between async/await and promises. We already have more of that than we need.