javascripttypescripttypestype-definition

Cancellable setTimeout based on promise


The following implementation throws an error (see comment below), how to resolve this?

interface PromiseWithAbort extends Promise<unknown> {
  abort: () => void
}

export const pause = (
  ms?: number,
  cb?: (...args: unknown[]) => unknown,
  ...args: unknown[]
): PromiseWithAbort => {
  let timeout

  // Error: Property 'abort' is missing in type 'Promise<unknown>'
  // but required in type 'PromiseWithAbort'.
  const promise: PromiseWithAbort = new Promise((resolve, reject) => {
    timeout = setTimeout(async () => {
      try {
        resolve(await cb?.(...args))
      } catch (error) {
        reject(error)
      }
    }, ms)
  })

  promise.abort = () => clearTimeout(timeout)

  return promise
}

Solution

  • The problem is that the promise you're assigning to promise doesn't have an abort property, but one is required by the type you've assigned promise. One simple way to fix that is to add it before assigning it to promise. (This will also let you get rid of the explicit type on promise.)

    There are a couple of other things as well, see *** comments:

    interface PromiseWithAbort extends Promise<unknown> {
        abort: () => void
    }
    
    export const pause = (
        ms?: number,
        cb?: (...args: unknown[]) => unknown,
        ...args: unknown[]
    ): PromiseWithAbort => {
        let timeout: number; // *** Need the type in order to avoid implicit `any`
      
        // *** Add `abort` to the promise before assigning to `promise`
        const promise = Object.assign(
            new Promise((resolve, reject) => {
                timeout = setTimeout(async () => {
                    try {
                        resolve(await cb?.(...args));
                    } catch (error) {
                        reject(error);
                    }
                 }, ms); // *** `ms` needs a default value, you're optionally passing `undefined`
            }), {
                abort: () => clearTimeout(timeout)
            }
        );
      
        return promise;
    }
    

    On the playground

    That said, using await on the promise returned by cb (if any) and passing the result into resolve is a bit round-about; instead, you can just pass the promise into resolve, which will resolve the promise you created to the one returned by cb (if any):

    export const pause = (
        ms?: number,
        cb?: (...args: unknown[]) => unknown,
        ...args: unknown[]
    ): PromiseWithAbort => {
        let timeout: number;
      
        // *** Add `abort` to the promise before assigning to `promise`
        const promise = Object.assign(
            new Promise((resolve, reject) => {
                timeout = setTimeout(() => { // *** No need for `async`
                    try {
                        resolve(cb?.(...args)); // *** No need for `await`, just resolve the promise to `cb`'s promise
                    } catch (error) {
                        reject(error);
                    }
                 }, ms);
            }), {
                abort: () => clearTimeout(timeout)
            }
        );
      
        return promise;
    }
    

    On the playground


    Just for what it's worth, I wouldn't add abort to the promise, not least because when you use .then or .catch on that promise or use it in an async function, the promise you get from them won't have the abort method. Instead, you might consider accepting an AbortSignal.

    I'd also remove cb and just make pause a pure pausing function. cb complicates it unnecessarily; you can just use .then or await and then call cb directly in your code.

    Here's an example:

    class CancelledError extends Error {
        constructor(msg = "Operation was cancelled") {
            super(msg);
        }
    }
    
    interface PauseOptions {
        signal?: AbortSignal;
        silent?: boolean;
    }
    export const pause = (
        ms: number,
        {signal, silent = false}: PauseOptions = {}
    ): Promise<void> => {
        return new Promise((resolve, reject) => {
            // Function we'll use if the operation is cancelled
            const cancelled = () => {
                if (!silent) {
                    reject(new CancelledError());
                }
            };
            // The actual timer
            const handle = setTimeout(() => {
                if (signal?.aborted) { // It would be rare for this to happen
                    cancelled();
                } else {
                    resolve();
                }
            }, ms);
            // Handle cancellation
            signal?.addEventListener("abort", () => {
                clearTimeout(handle);
                cancelled();
            });
        });
    };
    

    On the playground