Leveraging a blazing-fast runtime: a (new) Go backend for PureScript

News #9:

FFI & DX: a Wasm AST Parser :mage:

One of the pain points with FFI in alternative backends is the boilerplate (manual boxing/unboxing, runtime dependencies). With gopurs, I wanted FFI to feel 100% native for Go developers.

To achieve this, the compiler uses a Go AST parser compiled to WebAssembly (ffi_gen.wasm) that analyzes your .go FFI files on the fly. It reads your Go signatures and automatically generates the perfect, highly-optimized bridging code.

1. Uncurrying & idiomatic native types

I also wanted to avoid the hassle of currying. In modern JS (ES6+), writing a => b => a + b is highly ergonomic. But in Go, manual currying quickly becomes a nightmare of nested func(interface{}) interface{} closures.

By analyzing your signatures, the Wasm parser allows you to write perfectly flat and strongly typed Go functions:

Before (Typical FFI boilerplate with manual currying & boxing):

func DoMath(arg0 gopurs_runtime.Value) gopurs_runtime.Value {
    return gopurs_runtime.Func(func(arg1 gopurs_runtime.Value) gopurs_runtime.Value {
        a := gopurs_runtime.Unbox[int64](arg0)
        b := gopurs_runtime.Unbox[int64](arg1)
        return gopurs_runtime.Box(a + b)
    })
}

After (gopurs FFI):

func DoMath(a int64, b int64) interface{} {
    return a + b
}

You just write pure Go. The generated bridge takes care of all the uncurrying and type conversions under the hood.

2. Flexible Effect semantics

In PureScript, Effect delays execution. Thanks to the Wasm parser, you can choose exactly how you want your native Go effects to behave, and the compiler will adapt the generated wrapper automatically:

Option A: Flattened (N+1 argument)
Perfect for simple, direct side-effects.

func Log(message string, _ interface{}) interface{} {
    fmt.Println(message)
    return nil
}

Option B: Curried (returning a closure)
Perfect for caching expensive pure work before the effect runs.

func MatchRegex(pattern string) func(interface{}) interface{} {
    compiled := regexp.MustCompile(pattern) // Evaluated ONCE!
    
    return func(_ interface{}) interface{} {
        return compiled.MatchString("...")  // Evaluated on Effect run
    }
}

The Wasm parser detects your Go function’s return type (func(...)) and automatically adapts the backend optimizer to preserve your partial application caching, without sacrificing the overall uncurrying performance.

Best of both worlds.

News #10:

Here we go!

gopurs can now emulate the behavior of the event loop with respect to “fibers”: the main thread will patiently wait for child threads to finish before automatically terminating itself. Just like our ol’ Node Event Loop pal, when it detects relevant child handles. No abrupt exits when your main() function returns while Aff tasks are still running in the background.

This was a major milestone to unlock the full potential of Aff in Go. And speaking of potential… I’ve just updated the extended benchmarks to include asynchronous operations, File I/O, and most importantly: parallelism. You can find the test here.

Since Go natively maps goroutines to multiple OS threads, gopurs implicitly transforms forkAff into true multi-core execution. The results on a heavily CPU-bound parallel workload, running 4 concurrent fib 42 computations (intentionally written in a naive, unoptimized recursive way to maximize CPU stress), speak for themselves:

Benchmark JS (V8) Arista ES (V8) Go (gopurs)
File I/O (10k writes/reads) ~ 401 ms ~ 442 ms ~ 421 ms
Aff Ops (Async Delays) ~ 11 ms ~ 10 ms ~ 11 ms
Parallelism (4x Fib 42) ~ 5863 ms ~ 6028 ms ~ 1181 ms :rocket:
Total Execution Time ~ 6403 ms ~ 6560 ms ~ 1690 ms

Kapture 2026-08-02 at 20.35.22

JS and Arista run Fib 42 sequentially on a single thread blocking the event loop, while Go successfully distributes the 4 heavy tasks across 4 different CPU cores, making it 4x faster for this specific workload).

Of course, the more cores you have, the wider the gap becomes. For example, testing the limits on a local machine with 10 cores (10 concurrent tasks), at home:

Benchmark JS (V8) Arista ES (V8) Go (gopurs)
Parallelism (10x Fib 42) ~ 15113 ms ~ 14690 ms ~ 1255 ms :rocket:

JS and Arista scale linearly and predictably choke, while Go devours the 10 tasks in roughly the same time it took for 4, making it ~12x faster. As you can see, Go’s performance has remained almost unchanged! There may only be a slight additional overhead due to orchestration (more children to manage).


It’s incredibly satisfying to write standard, pure PureScript code with Aff and forkAff, and get true, unboxed multi-threading entirely for free on the backend. The perf gain is awesome. This is quite similar to what TS devs have seen in their comptime with v7 (their recent game-changing major release). But here, that’s pure runtime progress.

A summary is available here.

Next step: put gopurs through its paces with integration tests (Postgres, S3, RabbitMQ…), complete the core lib FFIs (e.g. parAff), etc.

1 Like

News #11:

100% green on the integration tests of b8x, but more importantly on the unit tests of Aff (gopurs-aff, mirror of purescript-aff) :white_check_mark: :tada:

Aff is one of the most complex and critical core libraries in the PureScript ecosystem. It handles asynchronous fibers, AVars, intricate error handling (bracket), cancellation, parallel execution (parAff), etc.

Getting all these tests to pass proves that the Go implementation (which relies natively on true multi-threading via goroutines and shared memory) is not just blazing fast, but semantically sound and strictly correct. We’ve successfully mapped the subtle edge cases of JS cooperative concurrency to Go’s parallel execution model without breaking the API or the expected behaviors.

To be even more precise: I found that a few original tests were actually somewhat “tainted” by the JS Event Loop conceptual model. They implicitly relied on synchronous execution happening before encountering the first blocking handler (which leaked in the assertions). In a true parallel environment like Go, where forkAff spawns an actual concurrent goroutine instantly, these hidden race conditions were immediately exposed. We had to slightly adjust those tests to be truly async-safe, which further proves the strictness and robustness of this new parallel foundation. Example: before and after.

Aff is now a pure abstraction, decoupled from the historical JS runtime.

Next steps: gopurs-arrays, gopurs-assert, etc.

:v:

News #12:

Finally done with the existing official tests for each module. They are all green now. :green_circle:


E.g. tests are passing for gopurs-arrays (Go-FFIed mirror of purescript-arrays). Of course, the PureScript code does not need to change, in your project(s). The signatures of all functions has been kept intact. The single thing that changes is the lib source in spago.yaml (e.g. Data.Array will target gopurs-arrays, not purescript-arrays).

I’m going to go back through them now and improve their coverage, by adding non-official tests. But the bulk of this looong work is behind me.

List of validated modules
  1. gopurs-argonaut-core

  2. gopurs-arrays

  3. gopurs-assert

  4. gopurs-avar

  5. gopurs-catenable-lists

  6. gopurs-console

  7. gopurs-datetime

  8. gopurs-effect

  9. gopurs-enums

  10. gopurs-exceptions

  11. gopurs-foldable-traversable

  12. gopurs-foreign

  13. gopurs-foreign-object

  14. gopurs-free

  15. gopurs-functions

  16. gopurs-integers

  17. gopurs-js-bigints

  18. gopurs-js-date

  19. gopurs-js-promise

  20. gopurs-js-promise-aff

  21. gopurs-js-uri

  22. gopurs-lazy

  23. gopurs-node-buffer

  24. gopurs-node-event-emitter

  25. gopurs-node-fs

  26. gopurs-node-http

  27. gopurs-node-net

  28. gopurs-node-path

  29. gopurs-node-process

  30. gopurs-node-streams

  31. gopurs-now

  32. gopurs-nullable

  33. gopurs-numbers

  34. gopurs-ordered-collections

  35. gopurs-partial

  36. gopurs-prelude

  37. gopurs-quickcheck

  38. gopurs-random

  39. gopurs-record

  40. gopurs-refs

  41. gopurs-run

  42. gopurs-simple-json

  43. gopurs-spec

  44. gopurs-st

  45. gopurs-strings

  46. gopurs-strings-extra

  47. gopurs-unfoldable

  48. gopurs-unsafe-coerce

  49. gopurs-uuid

  50. gopurs-variant

  51. gopurs-yoga-json

Of course, other modules will be supported in the future (feel free to open a PR if needed).