Writing in the front

It has been two months since my last article. In the past two months, I have been busy with the internship interview, so the output of the article was slightly delayed (actually lazy). Finally, I got the offer from a big factory, and now I have started my internship, which is the first good news of 2021. So, without further elaboration, implement A Promise (typescript version) that conforms to the Promise/A+ specification. This time let’s implement a typescript version of async/await.

There are many articles on the principle of async/await, but since this article is written in typescript, our async/await should be able to automatically infer the result from the function that the user incoming. So how to write typescript definitions for them is also an important part of this article.

What is the async/await

In the words of the Little Red Book, “async/await” is an asynchronous function, an application of Promise in functions, a new specification in ES2017 (ES8). This new feature enables code written synchronously to be executed asynchronously.

Basic usage

The specific method of use is not described here, the following is the general method of use:

// success
async function fn1() {
  /* If I await a Promise, I can directly take out the value of the Promise which is very sad. If it is a non-promise value, the await can be regarded as non-existent and will not have any practical effect. * /
  const res = await new Promise<string> ((resolve, reject) = > {
    setTimeout(() = > {
      resolve('fulfilled')},2000)})console.log(res) // fulfilled
  return res
}
// error
async function fn2() {
  try {
    /* If the state of an await Promise is rejected, an async error will be raised. This error will not prevent the program from running without a try/catch because async is essentially a Promise layer outside the function. Will directly trigger UnhandledPromiseRejectionWarning * /
    const res = await new Promise<string> ((resolve, reject) = > {
      setTimeout(() = > {
        reject('rejected')},2000)})return res
  } catch (error) {
    console.log(error) // rejected
    return Promise.reject(error)
  }
}
Copy the code

The return value of the async/await function will be explained in detail here, as mentioned earlier,Async wraps a layer of Promise around the entire function, so the return value inside the function is equivalent to the Promise valuethenThe return value in the callback, the return value will be wrapped in the Promise.

nature

OK. I assume that you are familiar with the basic usage of async/await and have heard the essence of it. This feature is essentially a syntactic candy made by Generator functions in ES6 and promises that you are familiar with. Generator functions return an Iterator interface. What are Generator functions and Iterator interfaces?

Iterator

An Iterator is an interface, or mechanism. It provides a unified access mechanism for various data structures, and any data structure can be iterated (that is, all the members of the data structure can be processed in turn) by deploying the Iterator interface. The main function is to provide a unified and simple access interface for all kinds of data structures, so that the members of the data structure can be arranged in some order.

Iterator is essentially a pointer object, implemented as follows:

  1. Creates a pointer object that points to the start of the current data structure.
  2. The first time a pointer object is callednextMethod to point to the first member of a data structure.
  3. The second call to the pointer objectnextMethod, which points to the second member of the data structure.
  4. That constantly calls the pointer objectnextMethod until it points to the end of the data structure.

Iterators in native data structures

There are a number of built-in data structures in JS that naturally have a default Iterator interface, including:

  • Array
  • Map
  • Set
  • String
  • The arguments object for the function
  • The NodeList object

To get the Iterator interface from these data structures, call the symbol. Iterator method:

// Array Symbol. Iterator method
const arr = ['a'.'b'.'c'];
const iter = arr[Symbol.iterator]();
// Iterate through each iterator with the next() method
iter.next() // { value: 'a', done: false }
iter.next() // { value: 'b', done: false }
iter.next() // { value: 'c', done: false }
iter.next() // { value: undefined, done: true }
Copy the code

Value is the value traversed each time, and done indicates whether the array is traversed completely.

A custom Iterator

As mentioned earlier, Iterator is only intended to provide us with a unified access interface, so any common object can implement Iterator by defining the symbol. Iterator method.

const iterObj = {
  value: 0[Symbol.iterator]() {
    const self = this
    return {
      next() {
        const value = self.value++
        const done = value > 2
        return {
          value: done ? undefined : value,
          done
        }
      }
    }
  }
}

const iter = iterObj[Symbol.iterator]()

iter.next() // { value: 0, done: false }
iter.next() // { value: 1, done: false }
iter.next() // { value: 2, done: false }
iter.next() // { value: undefined, done: true }
iter.next() // { value: undefined, done: true }
Copy the code

Our implementation of the Iterator interface needs to have a next method (this method will be typed as for… In the next method, the completed state and the unfinished state should be judged. When all iterations are completed, done should be set to true, and the value returned should be undefined.

In this article, we only need to understand the basic concepts of Iterator. If you want to learn more about Iterator, you can check out MDN.

Other methods

In addition to the next method that must be implemented, iterators can have two optional methods, return and throw.

The return method

The return method is used to specify the logic to be executed when the iterator is prematurely closed. Iterators can be “closed” when we don’t want to run out of iterables to iterate over. Possible scenarios include:

  • for... ofCycle throughbreak,continue,returnorthrowQuit early.
  • The deconstruction operation does not consume all values.

For example, if you call the return method on an iterator object returned by a Generator function, it can be prematurely closed:

function* gen() {
  yield 1;
  yield 2;
  yield 3;
}

const g = gen();

console.log(g.next());// { value: 1, done: false }
// This method can also pass arguments. It is useless to pass arguments to the return method of a normal iterator
console.log(g.return('foo'))// { value: "foo", done: true }
console.log(g.next());// { value: undefined, done: true }
Copy the code

As you can see, the iterator is already complete after the return method is called.

It is important to note that, because this method is optional, not all iterators are closed. For example, arrays cannot be closed:

const a = [1.2.3.4.5]
const iter = a[Symbol.iterator]()
for (const i of iter) {
	console.log(i)
    if(i > 2) {
    	break}}/ / 1
/ / 2
/ / 3

for (const i of iter) {
	console.log(i)
}
/ / 4
/ / 5
Copy the code

Of course, this method isn’t actually used in this article, so it’s an extra extension.

Throw method

The throw method is used primarily in conjunction with Generator functions, and is not used for generic iterators, as discussed in Generator.

Generator

Generator is a new data type introduced in ES6. It is essentially an implementation of JS coroutines. As for what coroutines are, we will not explore further here.

The difference with ordinary functions

  • When I do my function declaration,functionThere is an asterisk between the keyword and the function name. At the same time, do not use the arrow function to declare, otherwise an error will be reported.
  • Instead of returning a normal function, a Generator function returns an iterator object that iterates over each state within the Generator function in turn.
  • Function body for internal useyieldExpression that defines different internal states.
  • The Generator function cannot be usednewClose the key word, otherwise an error will be reported.

How to use

Since this article focuses on how to implement async/ AWIAT, the usage of Generator functions only covers the basic usage and the parts related to the implementation of async/await.

function* helloWorldGenerator() {
  yield 'hello';
  yield 'world';
  return 'ending';
}

const hw = helloWorldGenerator();
console.log(hw);

console.log(hw.next());// {value: "hello", done: false}
console.log(hw.next());// {value: "world", done: false}
console.log(hw.next());// {value: "ending", done: true}
console.log(hw.next());// {value: undefined, done: true}
Copy the code

An explanation of the code above:

  • The function does not run after a Generator call and does not return the result of the function’s operation, but returnsIteratorsObject, internalyieldExpressions are states, and the value after the expression is returned as the value of that state, so there are three states in this function:hello,worldandretrunStatement end execution statusending
  • If you want to run each inside the Generator functionyieldPhase, the iterator must be callednext()Method that causes the state pointer inside its function to move to the next state each time it is callednext()Method, the internal pointer executes from the head of the function or from where it stopped at the previous level until it moves to the next levelyiedlExpression or encounterreturnStatement (encounteredreturnThe statement function will stop directly) and can still be called after stoppingnext()Method, but returned at this timevalueforundefined.

yield

There is nothing special about the yield expression. It simply represents a pause sign. The value after the yield expression corresponds to the value of a phase, which will be used as the value of the object’s value property after the corresponding next() call.

The iterator’s next() method argument

Yield itself returns no value, or undefined is always returned. The next method of an iterator returned by a Generator function can take an argument that is treated as the return value of the previous yield statement.

function* f() {
  const a = yield 1
  console.log(a) // 'a'
  const b = yield 2
  console.log(b) // 'b'
}

const g = f()

g.next() // { value: 1, done: false }
g.next('a') // { value: 1, done: false }
g.next('b') // { value: undefined, done: true }
Copy the code

When passing in arguments, we should start with the second next method. Since the first next method runs from inside the function, there is no yield expression at the front, so the arguments in the first Next method have no effect. If we want to pass in arguments at the beginning, Arguments to the Generator function should be passed when an iterator object is generated.

function* f(name:string) {
  const a = yield name
  console.log(a) // 'a'
  const b = yield 2
  console.log(b) // 'b'
}

const g = f('Coloring')
g.next() // { value: 'Coloring', done: false }
Copy the code

As a Generator function runs from paused to resumed, its context state is invariant. With the parameters of the next method, there is a way to continue injecting values into the function body after the Generator function has started running. That is, the behavior of the Generator function can be adjusted by injecting different values from outside to inside at different stages of its operation.

Iterator throw() method

As mentioned earlier, Generator iterators have a third method, throw, which, like return, forces the Generator to be closed.

function* generatorFn() {
  for (const x of [1.2.3]) {
    yield x
  }
}

const g = generatorFn()

console.log(g) // generatorFn {<suspended>}

try {
  // A synchronization error will be thrown if no processing is done
  g.throw('foo')}catch (error) {
  console.log(error) // foo
}

console.log(g) // generatorFn {<closed>}
Copy the code

However, if we handle this error inside the Generator function, the Generator does not shut down and can resume execution. The error skips the corresponding yield, as follows:

function* generatorFn() {
  for (const x of [1.2.3]) {
    try {
      yield x
    } catch (error) {
      console.log(error) // foo}}}const g = generatorFn()

console.log(g.next()) // { value: 1, done: false }
g.throw('foo')
console.log(g.next()) // { value: 3, done: false }
Copy the code

As you can probably guess, we can use this method to simulate async/await. Then we can throw it manually by getting the Reason of the Rejected state Promise.

Implement async/await

This is all preparation for async/await. From Generator functions we know that yield can be used as a signal to suspend a function, but each time we continue we need to manually call the iterator’s next method. The essence of async/await is to simplify this manual invocation and allow Generator functions to iterate automatically.

When we use async/await before, we find that the value followed by await or the Promise of the depressing state will be directly returned as the value of the expression. As long as we can yield the value immediately following the yield expression as the return value, and wrap the return value of Generator functions with a Promise, can we successfully implement async/await?

In fact, someone has already implemented a corresponding library based on this principle, such as co, so we can do a simple implementation of it in turn.

Implement the wrapper function

The wrapper function runs the incoming callback Generator, takes its generated iterator object, calls the iterator internally, and returns a Promise bound to each step of the iterator.

Type probe

Let’s first look at what the type definition looks like when using the async/await function:

async function fun(a: string) {
  return a
}
Copy the code

When returning a Promise:

async function fun(a: string) {
  return Promise.resolve(a)
}
Copy the code

As you can see, typescript automatically deduces type definitions for functions and automatically unpacks returned promises.

In this case, we can first declare the corresponding tool type:

// PromiseLike is a definition file defined globally in ES6, so it is not necessary to introduce the definition here, which is generally an instance of a class of Promise
interface PromiseLike<T> {
    /**
     * Attaches callbacks for the resolution and/or rejection of the Promise.
     * @param onfulfilled The callback to execute when the Promise is resolved.
     * @param onrejected The callback to execute when the Promise is rejected.
     * @returns A Promise for the completion of which ever callback is executed.
     */
    then<TResult1 = T, TResult2 = never>(onfulfilled? : ((value: T) = > TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected? : ((reason: any) = > TResult2 | PromiseLike<TResult2>) | undefined | null): PromiseLike<TResult1 | TResult2>;
}

type ResolveValue<T> = T extends PromiseLike<infer V> ? V : T
Copy the code

The tool types above help us parse out the types of Promise success states.

Let’s look at a type definition that normally has a return value Generator:

function* fun(a: string) {
  const b = yield 'b'
  const c = yield Promise.resolve('c')
  const d = yield Promise.resolve('d')
  return a
}
Copy the code

When returning a Promise:

function* fun(a: string) {
  const b = yield 'b'
  const c = yield Promise.resolve('c')
  const d = yield Promise.resolve('d')
  return Promise.resolve(a)
}
Copy the code

As you can see, a Generator function returns a Generator type that accepts three generic parameters, which we can vaguely guess on the way:

  • The first parameter is the type of the value that returns the state each time the yield expression is used. This parameter is automatically inferred from the value of the returned state and all types are merged.

  • The second parameter is the return value of the Generator function, but we can see that the type of the value is different when we return a normal value and a fulfilled Promise, while the async/await type is the same and the Promise can be unwrapped. As you can guess, we need to type it using the previously defined tool type ResolveValue.

  • The third parameter is not clearly visible in the type definition, so let’s look at the source definition:

    interface Generator<T = unknown, TReturn = any, TNext = unknown> extends Iterator<T, TReturn, TNext> {
        // NOTE: 'next' is defined using a tuple to ensure we report the correct assignability errors in all places.next(... args: [] | [TNext]): IteratorResult<T, TReturn>;return(value: TReturn): IteratorResult<T, TReturn>;
        throw(e: any): IteratorResult<T, TReturn>;
        [Symbol.iterator](): Generator<T, TReturn, TNext>;
    }
    Copy the code

    As you can see, the first three arguments are defined as TNext and are used in two places in the type definition, one in the argument to the next method and the other in the third argument to the inherited Iterator interface. The Iterator interface actually looks like this:

    interface Iterator<T, TReturn = any, TNext = undefined> {
        // NOTE: 'next' is defined using a tuple to ensure we report the correct assignability errors in all places.next(... args: [] | [TNext]): IteratorResult<T, TReturn>;return? (value? : TReturn): IteratorResult<T, TReturn>;throw? (e? :any): IteratorResult<T, TReturn>;
    }
    Copy the code

    Therefore, we can see that the third parameter is actually the parameter type of the next method, and we can see from our previous knowledge of Generator functions that the value passed in the next method is the return value of the Generator function.

    The third argument to the Generator interface is the combination of all yield return value types. Unfortunately, we are currently unable to use this parameter to prompt us because the yield expression is used inside the Generator function and we cannot change its type externally unless we manually annotate it:

    function* fun(
    a: string
    ) :Generator<Promise<string> | 'b', Promise<string>, string> {
      const b = yield 'b'
      const c = yield Promise.resolve('c')
      const d = yield Promise.resolve('d')
      return Promise.resolve(a)
    }
    Copy the code

    As you can see, we can still use the type of the generic to provide hints for us, but we can also see the disadvantages of this writing. It is particularly difficult to define the three parameters of the generic, and since it is a merged type, the hint for the type is actually very bad. It is best to define the type directly inside a function when declaring variables:

    function* fun(
    a: string
    ) {
     const b: string = yield 'b'
     const c: string = yield Promise.resolve('c')
     const d: string = yield Promise.resolve('d')
     return Promise.resolve(a)
    }
    Copy the code

    Note:

    • In fact, the automatic inference of the third generic parameter itself is received by internal requirementsyieldThe type of the variable that returns the value is inferred backwards, but since we are simulating async/await, we are thinking from the forward definition definition.You can see that the value of the third generic parameter is automatically added.
    • Async /await is something that can be inferred automatically without any type definitionawaitThe type after the expression, and it willfulfilledState promises are unpacked automatically, so the types are slightly different, mainly becauseyieldandawaitThe original design usage is not consistent, we just useyieldThis pause sign to simulateawait“, so it can’t be perfectly reproduced.

    The hole I stepped in

    In the beginning I also wanted to do a can automatically parse incoming function types of packaging function, because we can see that because it is automatic iteration, so the type of the first argument should be and the third parameter types are consistent, we only need to put the two set to the same, but it is not reasonable, What I need to do is automatically infer generic parameter values for the Generator interface from the function itself, and then directly change its generic parameter values, which typescript currently does not support.

    Then I decided to use type assertion, so that I could just write the corresponding type definition. The method of completing all the parameters by myself was a bit unacceptable, so I defined another tool type:

    type AsyncFunction<T> = T extends (
    ...args: infer A
    ) => Generator<infer Y, infer R, unknown>
      ? (. args: A) = > Generator<Y, ResolveValue<R>, ResolveValue<Y>>
      : never
    Copy the code

    The original idea was to pass in the type of the function itself to do type inference again, but after writing it, I found that the self-reference problem of the function itself was ignored. In order to achieve automatic type inference, I needed to write an identical function funClone externally. Then use AsyncFunction

    to do forced type inference, which is obviously more work, and there are also type conflicts, so give up (cry).

Ok, now that we have explored the type definitions for the Generator, we need to design the type definitions for our wrapper functions based on the generic parameters it provides.

The type definition

First, we need to make it clear that what we are doing is a wrapper function, i.e. a higher-order function, so we should return a function whose type definition should be the same as that of the function we are defining with async/await.

function _asyncToGenerator<R.T = unknown.A extends Array<any> = Array<any> > (fn: (... args: A) => Generator<T, R,any>
) : (. args: A) = >Promise<ResolveValue<R>> {
  // ...
}
Copy the code

Above is the type definition of our wrapper function. Let’s look at it step by step:

  • Use generic parameters: if we want to write a automatically depending on the type of the incoming value infer the follow-up type of interface, the generic parameter is a must, in the corresponding position to fill in the corresponding generic parameter, we can let the typescript automatic generic parameter assignment for us, and in other places can also automatically when using the inference.

  • Put the R generic (that is, the return value of the Generator function) first: Since T and A are only used for type inference, R can be used for manual input to control the return value of Generator functions. In contrast to async/await, when the return value type is not clear, we can also manually annotate the return value of async/await functions to control their return type.

  • The passed parameter is a Generator function: since we need type inference from this passed function, we need to use the generic parameter here to get the type.

  • The return value is a function that returns a Promise: Fetching the type of the function passed in as a Generator, as we originally required, is to make proper type inference about the wrapped function.

    The parameter type of this function is the same as that of the Generator function, and the return value type is the result of unpacking the return value type of the Generator function with a Promise wrapped around it, just as the async/await function.

Logic to write

After a while of talking, I’m finally getting into the actual logical code. As I said at the beginning, how to write typescript definitions is also an important part of this article, which is probably the obsession of typescript enthusiasts (laughs). In my understanding, an overall perception of requirements based on type definition can make subsequent business development faster. The following is written from the perspective of type definition:

function _asyncToGenerator<R.T = unknown.A extends Array<any> = Array<any> > (fn: (... args: A) => Generator<T, R, ResolveValue<T> |any>
) : (. args: A) = >Promise<ResolveValue<R>> {
  // We need to return a function
  return function (this: void. args) {
    // The inner this needs to display the defined type, since the function is not a constructor, the type void will do
    const self = this
    // Requirement 2: The function value that returns the function needs to be wrapped in a Promise
    return new Promise(function (resolve, reject) {
      // Requirement 3: The return value of the returned Promise is the return value of the Generator. How do we get this return value? We need to get the iterator and continue until the done state is true for the first time
      // Get an iterator instance, passing the outer function as this
      const gen = fn.apply(self, args)
      // Because we want to do automatic iteration, we need to wrap the next and throw methods
      // Go to the next step
      function _next(. nextArgs: [] | [T]) {
       // Requirement 4: Use yield to simulate await, and iterate automatically. The value attribute of the return value of the next method is the argument to the next method
       // We need to wrap an automatic iteration function asyncGeneratorStep inside
       // asyncGeneratorStep()
      }
      // Requirement 5: Throw a synchronization exception inside the Generator when a Promise from Rejected is encountered
      function _throw(err: any) {
       // Since we need to iterate further if an exception is caught, we also need to use the automatic iteration asyncGeneratorStep function
       // asyncGeneratorStep()
      }
      // Requirement 6: Run iterators automatically, so we need to start iterators inside the function
      _next()
    })
  }
}
Copy the code

Through the requirements analysis and type definition we write the most outer wrapping function should be roughly to achieve the function, the next is the internal automatic iteration function asyncGeneratorStep code.

Implementing iterative functions

The type definition

Since iterating functions are only internal, the user is not aware of them in the outer layer, so we won’t explore how to write a type definition here. We just need to make sure that the type we define will help us write the function’s logic properly.

function asyncGeneratorStep<
  R.TNext = unknown.T extends Generator = Generator> (
  // Generator (iterator) instance
  gen: T,
  // Wrap the Promise's resolve function, and the last value after iteration is the return value
  resolve: (value: R) => void.Reject () {reject () {reject () {reject () {reject () {reject ();}reject: (reason? :any) = >void.// We wrapped our own next and throw functions above_next: (... args: [] | [TNext]) =>void,
  _throw: (err: any) = >void.// Continue iterating or throw an error
  key: 'next' | 'throw'.// There is only one argument, which needs to satisfy both next and throw, so we can use anyarg? :any
) :void { // No return value is required because the callback function is used
// ...
}
Copy the code

Logic to write

Before writing the code, let’s take a look at the execution logic:Following the diagram above and the type definition, we can write logical code very quickly:

function asyncGeneratorStep<
  R.TNext = unknown.T extends Generator = Generator> (
  gen: T,
  resolve: (value: R) => void, reject: (reason? :any) = >void, _next: (... args: [] | [TNext]) =>void,
  _throw: (err: any) = >void,
  key: 'next' | 'throw', arg? :any
) :void {
  // Add a try... Catch catches uncaught exceptions inside the Generator function
  try {
    The yield expression, whether key is next or throw, returns the same structure in order to iterate further down
    const { value, done } = gen[key](arg)
    if (done) {
      // The iterator completes and resolves directly
      resolve(value)
    } else {
      // Make all values Promise, if the value passed in is a Rejected Promise, throw a synchronization error, otherwise continue iterating
      Promise.resolve(value).then(_next, _throw)
    }
  } catch (error) {
  	// If an exception is not caught inside the Generator, reject it directly
    reject(error)
  }
}
Copy the code

As you can see, the main iteration function code is not complicated, just figuring out how the state should be handled at each stage.

All the code

All the code for handwriting async/await has been explained, here is the full code:

type ResolveValue<T> = T extends PromiseLike<infer V> ? V : T

function _asyncToGenerator<R.T = unknown.A extends Array<any> = Array<any> > (fn: (... args: A) => Generator<T, R,any>
) : (. args: A) = >Promise<ResolveValue<R>> {
  return function (this: void. args) {
    const self = this
    return new Promise(function (resolve, reject) {
      // Get the instance
      const gen = fn.apply(self, args)
      // Go to the next step
      function _next(. nextArgs: [] | [T]) {
        asyncGeneratorStep(
          gen,
          resolve,
          reject,
          _next,
          _throw,
          'next'. nextArgs ) }// Throw an exception
      function _throw(err: any) {
        asyncGeneratorStep(gen, resolve, reject, _next, _throw, 'throw', err)
      }
      // Start the iterator
      _next()
    })
  }
}

function asyncGeneratorStep<
  R.TNext = unknown.T extends Generator = Generator> (
  gen: T,
  resolve: (value: R) => void, reject: (reason? :any) = >void, _next: (... args: [] | [TNext]) =>void,
  _throw: (err: any) = >void,
  key: 'next' | 'throw', arg? :any
) :void {
  try {
    const { value, done } = gen[key](arg)
    if (done) {
      resolve(value)
    } else {
      Promise.resolve(value).then(_next, _throw)
    }
  } catch (error) {
    reject(error)
  }
}
Copy the code

Test it out:

const asyncFunc = _asyncToGenerator(function* (param: string) {
  try {
    yield new Promise<string> ((resolve, reject) = > {
      setTimeout(() = > {
        reject(param)
      }, 1000)})}catch (error) {
    console.log(error)
  }

  const a: string = yield 'a'
  const d: string = yield 'd'
  const b: string = yield Promise.resolve('b')
  const c: string = yield Promise.resolve('c')
  return [a, b, c, d]
})

asyncFunc('error').then((res) = > {
  console.log(res)
})

// error
// ['a', 'b', 'c', 'd']
Copy the code

Also, types can be successfully inferred, nice.

conclusion

This article uses typescript to implement an async/await from scratch based on type definitions. It focuses on typescript type definitions and the automatic iteration logic of Generator functions. The author’s skills are limited. If there are any mistakes or omissions, please point them out in the comments section and ask 👍.

The resources

All kinds of source code implementations that you want have -async /await implementations here

MDN – Generator

JavaScript Advanced Programming (version 4)