Mark's notes on software development

How I Implemented Golang's `chan` in TypeScript

One evening, with nothing much to do, I came up with a crazy idea. I was working on some asynchronous tasks in TypeScript and felt the urge to integrate a few Golang-style channels: one for data, another for cancellation signals, and a third for other types of interruptions. My goal was to be able to loop through and use select to determine which of these three channels was available for reading or writing.

The existing npm packages offered channels as asynchronous iterators, but I needed more functionality—I aimed for nothing less than full select capability. So, I decided to create another implementation of channels. This time, I aimed to bring maximum Golang-esque functionality to the Node.js world. Introducing the library @harnyk/chan!

What can you do with it? Let's dive in.

The Basics

First of all, what is a channel? It's a communication primitive between two goroutines, a way of sending and receiving values between them. Here's how it works in Go:

package main

import "fmt"

func main() {
    c := make(chan int)
    go func() {
        c <- 42
        fmt.Println("sent 42")
    }()
    fmt.Println(<-c)
}

In one goroutine, we send a 42 to the channel, and in the main goroutine, we receive it.

In JavaScript, we don't have truly parallel micro-threads like goroutines, but we do have asynchronous functions that can interrupt themselves to perform I/O, allowing others to run simultaneously. If we consider an async function a poor man's goroutine, we can translate the above Go code into the following TypeScript code:

import { Chan } from '@harnyk/chan';

async function main() {
    const c = new Chan<number>();
    (async function () {
        await c.send(42);
        console.log('sent 42');
    })();
    console.log(await c.recv());
}

In Go, both ch<- and <-ch operators block the current goroutine. In TypeScript, the "blocking" is emulated with promises. You must await the value to be received from or sent to the channel.

Buffering

In Go, channels have another interesting feature—buffering. By default, a channel does not have an internal buffer or message queue, but this behavior is configurable. If we want to ensure that sending to a channel does not block a goroutine until a certain point, we can specify the buffer size.

This is how we do it in Go:

package main

import "fmt"

func main() {
    c := make(chan int, 3)

    c <- 1
    c <- 2
    c <- 3
    fmt.Println(<-c)
    fmt.Println(<-c)
    fmt.Println(<-c)
}

In this example, we first send three values to the channel, and then we receive three values from the channel. We can do this in the same goroutine because the sender won't block until the channel's buffer is full. Here is the same code in TypeScript:

import { Chan } from '@harnyk/chan';

async function main() {
    const c = new Chan<number>(3);

    await c.send(1);
    await c.send(2);
    await c.send(3);
    console.log(await c.recv());
    console.log(await c.recv());
    console.log(await c.recv());
}

Iteration

In Go, we can iterate over the values in a channel using a for loop with range:

package main

import "fmt"

func main() {
    c := make(chan int)

    go func() {
        c <- 1
        c <- 2
        c <- 3
        close(c)
    }()

    for v := range c {
        fmt.Println(v)
    }
}

My TypeScript channel implementation is asynchronously iterable. Asynchronous iterators are one of the coolest and yet one of the most underrated features of modern JavaScript. You can easily iterate over a channel with for await:

import { Chan } from '@harnyk/chan';

async function main() {
    const c = new Chan<number>();

    (async function () {
        await c.send(1);
        await c.send(2);
        await c.send(3);
        c.close();
    })();

    for await (const v of c) {
        console.log(v);
    }
}

Selecting

And here we approach a mind-blowing feature. Yes, my channels support select! In Go, select is used everywhere, and once you get used to it, you can't imagine working without it. I want to introduce the TypeScript community to select as another asynchronous primitive, alongside Promise and async/await.

What is select in Go?

select in Go is a powerful control structure that allows a goroutine to wait on multiple communication operations. It's similar to a switch statement, but for channels. With select, you can listen to multiple channels and perform operations on the first one that becomes ready. This feature is incredibly useful for building concurrent programs where you need to handle multiple asynchronous events. It allows you to manage timeouts, cancellation signals, and other asynchronous activities seamlessly.

Here's a simple example in Go:

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan string)

    go func() {
        // Simulate a long-running task
        time.Sleep(2 * time.Second)
        ch <- "task completed"
    }()

    select {
    case result := <-ch:
        fmt.Println(result)
    case <-time.After(1 * time.Second):
        fmt.Println("task timed out")
    }
}

In this example:

This pattern is extremely useful for handling operations that may hang or take too long, providing a clean way to enforce timeouts and manage asynchronous tasks effectively.

Let's port it to TypeScript:

import { Chan, select } from '@harnyk/chan';
import { setTimeout } from 'node:timers/promises';

function timeAfter(delay: number) {
    const ch = new Chan<void>();
    setTimeout(delay).then(() => ch.send());
    return ch;
}

async function main() {
    const ch = new Chan<string>();

    (async function () {
        await setTimeout(2000);
        await ch.send('task completed');
    })();

    await select()
        .recv(ch, ([value, ok]) => {
            console.log(value, ok);
        })
        .recv(timeAfter(1000), () => {
            console.log('task timed out');
        });
}

In this example, we created a timeAfter function, which returns a channel that is sent a value after a specified delay. We then use select to wait for either the task to complete or a timeout of 1 second.

More on Select

In this quick blog post, only the recv select operation is covered, though send and default are also fully supported.

Currently, the best usage documentation available is the test suite in the GitHub repository.

There are also many tests, striving for near 100% coverage, which can be used for reference.

Conclusion

Implementing Golang's chan in TypeScript has been an exciting journey. By bringing the powerful and versatile channel and select constructs to the TypeScript community, I hope to enhance the way we handle asynchronous operations in JavaScript. With features like buffering, asynchronous iteration, and select, this library aims to provide developers with the tools to write cleaner, more efficient concurrent code.

The @harnyk/chan library opens up new possibilities for asynchronous programming in TypeScript, making it easier to manage complex workflows, handle timeouts, and coordinate multiple asynchronous tasks. I encourage you to explore the library, experiment with the examples, and dive into the test suite for more usage scenarios.

Happy coding, and may your asynchronous tasks always be well-coordinated!

#golang #library #programming #tutorial #typescript