DEV Community

Ajay Bhosale
Ajay Bhosale

Posted on • Updated on

async, await, and keeping your promises

The async function and await keyword introduced as part of ECMAScript 2017 do provide very useful syntactic sugar on top of promises. Promise in itself provides alternative to callbacks when writing asynchronous code. Promises can be chained, its inbuilt methods like all, any, and race helps to manages multiple asynchronous tasks.

Check the following example, here getData function mimics asynchronous behavior. In real world, you can think it as your data layer, using functions like fetch or a 3rd party library which still using callbacks for asynchronous programming.

const getData = (n: number) => {
    return new Promise<number>((res, rej) => {
        if (n === 3) {
            rej('Can not use 3.');
            return;
        }
        res(n * n);
    });
}

If I have to, fetch data for 2, and based on that response fetch data for 3 and 4, then the code will look like something below.

const check = () => {
    getData(2)
        .then(x2 => {
            console.log(x2);
            return getData(3);
        })
        .then(x3 => {
            console.log(x3);
            return getData(4);
        })
        .then(x4 => {
            console.log(x4);
        }).catch((ex) => { // This is catch handler
            console.log('Error occurred : Check with Promise.');
            console.log(ex);
        });
}

If we used async and await, same code will be more readable and easy to understand.

const check = async () => {
    try {
        const x2: number = await getData(2);
        console.log(x2);
        const x3: number = await getData(3);
        console.log(x3);
        const x4: number = await getData(4);
        console.log(x4);
    } catch (ex) { // This is catch block
        console.log('error occurred : check with async and await.');
        console.log(ex);
    }
}

Error handling, is still a challenge. If a promise is rejected, then either the catch handler get executed, or an exception will be thrown. With await keywords, only way to handle rejected promise is try-catch block.

This may work for some cases, but what if you are fine with errors while loading data for 3 and 4. The catch block does not give a good way to handle control flow. You may end up having separate try-catch blocks for each await, and that will worsen the problem.

Languages like go, has a different philosophy to handle errors. It segregates error from exception, and communicate errors with ordinary values as return parameters.
Lets see what happens when we try that philosophy here.

Always keep your promises

Let change the getData function so that it can never reject the promise. The promise will be always resolved, and errors will be reported via a return type.

type PromiseResponse<T> = Promise<[string] | [null, T]>;

const getData = (n: number) : PromiseResponse<number> => {
    return new Promise((res) => {
        if (n === 3) {
            // no reject here 
            res(['Can not use 3.']);
            return;
        }
        res([null, n * n]);
    });
}

I have declared a Type here PromiseResponse, which is a Promise returning tuple, and will assist TypeScript for better syntax checking.

  • First item will be error : string or null.
  • Second item will be actual result of Type T or undefined.
const check3 = async () => {
    const [e2, x2] = await getDataV2(2);
    // Here for TypeScript x2 is either number or undefined
    if (x2 === undefined) {
        console.log('Error while fetching data for 2');
        return;
    }
    // As x2 is checked for undefined
    // at this line x2 is number
    console.log(x2);

    // now fetch data for 3 and 4
    const [e3, x3] = await getDataV2(3);
    if (x3 !== undefined) {
        console.log(x3);
    }

    const [e4, x4] = await getDataV2(4);
    if (x4 !== undefined) {
        console.log(x4);
    }
}

With the new approach, code don't need to use try-catch block and we have better control over flow.

I am using this techniques for application layer, which seats between UI and underlying data, and its make life much easier.

Based on your requirements, you can extend the Type PromiseResponse into a class, and with helper methods like Success and Error to make your code more readable.

I have a utility function, named as aKeptPromise on propose. With this function getData is more readable.

function aKeptPromise<T>(
  callback: (
    success: (result: T) => void,
    failure: (error: string) => void
  ) => void
): PromiseResponse<T> {
  return new Promise((res) => {
    callback(
      (r) => res([null, r]),
      (e) => res([e])
    );
  });
}

const getDataV3 = (n: number) : PromiseResponse<number> => {
    return aKeptPromise((success, failure) => {
        if (n === 3) {
            failure('Can not use 3.');
            return;
        }
        success(n * n);
    });
}

TypeScript Playground

Thanks for reading. Let me know if you have comments.

Top comments (0)