DEV Community

Cover image for ⚡️ Leverage Go superpowers with PureScript! Native speed w/ absolute type safety
Kevin 心学
Kevin 心学

Posted on

⚡️ Leverage Go superpowers with PureScript! Native speed w/ absolute type safety

When you combine the absolute safety and elegance of a purely functional language with the raw execution speed of a modern low-level language, it's like discovering that space and time are inextricably linked: a whole new dimension opens up to you.


In my previous article, I introduced phpurs, a compiler backend that brings the absolute mathematical safety of PureScript to the 70% of the web that still runs on PHP. The goal was to prove that we do not have to sacrifice modern safety and ergonomics just because we target a runtime that is, at first glance, not the most natural.

Today, we are looking at the exact opposite end of the spectrum. If PHP was about ultimate portability and legacy compatibility, what happens when we want pure, raw metal speed? What happens when we want true multi-core concurrency, static native binaries, and a garbage collector designed for extreme high-throughput?

Say hello to one of my recent projects: gopurs, a super-optimized PureScript-to-Go compiler.

And let me spoil the end of the story right away: by combining the high-level semantic purity of PureScript with the raw execution force of modern Go, the final compiled code matches Chez Scheme speed on pure computational benchmarks. Yes, Chez Scheme, one of the absolute historical champions of functional AOT (Ahead-Of-Time) compilation (i.e., decades of compiler optimizations).

This will be a shorter article in the series, but here is a quick version of the story...


Breaking the AOT ceiling: how TAST changed everything

Historically, compiling highly polymorphic functional languages (like PureScript or Haskell) to statically typed languages like Go has often been a nightmare.

Difficulty

Older attempts often relied on mapping everything to Go's interface{} (or any). It works, but it's a performance sacrifice. In Go, every primitive value you assign to an interface{} is "boxed"—meaning it is wrapped inside a heavier memory object and escapes to the heap. When done millions of times per second in a functional loop, it generates massive pressure on Go's Garbage Collector, bringing execution speed to a crawl. To solve this, gopurs initially used a flat Value struct (a tagged union, inspired by V8), keeping things mostly on the stack.

It was much faster, but it wasn't enough. The real bottleneck was the lack of structural type information. When the PureScript compiler parses your code, it translates it into a simplified intermediate representation (IR) called CoreFn. Because CoreFn was historically designed to target JavaScript (a dynamically typed language), it intentionally throws away all the static type information (this is called type erasure). The Go backend was therefore flying blind, forced to generate generic, defensive code.

This is where the Typed Abstract Syntax Tree (TAST) comes in. By relying on a custom fork of the PureScript compiler and the brilliant purescript-backend-optimizer (that you can find here), gopurs intercepts the compilation pipeline before type erasure. It preserves deep type information all the way down to the code generation phase. This allows the compiler to perform strict monomorphization, for example.

Zero boxing on the hot paths. Zero overhead. The abstractions literally melt away at compile time, leaving only blazing-fast imperative loops that the Go compiler can aggressively optimize.

Fearless Concurrency: Aff meets Goroutines 🚥

One of the greatest strengths of PureScript is its Aff monad: a powerful, composable way to handle asynchronous effects. In the JavaScript world, Aff is carefully mapped to the single-threaded Event Loop.

But Go doesn't have a single-threaded Event Loop. Go has goroutines and true, shared-memory parallelism across multiple OS threads.

Instead of trying to fake a JS-like event loop, gopurs maps Aff and forkAff directly to native goroutines. This means that when you write standard, pure PureScript asynchronous code, you are implicitly getting true multi-core execution for free.

Speed

I benchmarked this on a heavily CPU-bound workload (running 10 parallel Fibonacci computations).

  • In JS (V8), the event loop gets blocked, and the 10 tasks execute sequentially in roughly ~15,000 ms.
  • In Go (gopurs), the 10 tasks are automatically distributed across the 10 CPU cores of the machine, finishing in ~1,250 ms.

It is roughly 12x faster for the exact same PureScript code. It's the absolute dream of developer experience, isn't it? You write pure, mathematically proven abstractions, and the backend orchestrates the parallelization on the hardware level. You don't have to care about low-level machinery, too often.

But... one minute...

Does this remind you of anything? Yes, it's the same ratio as for the recent upgrade from TypeScript compiler v6 to v7. Except that in this case, the gain isn't limited to compile time. It extends to runtime as well!

FFI without the pain: the WebAssembly parser

A language is only as good as its ecosystem, and cross-compilation often suffers from terrible FFI (Foreign Function Interface) boilerplate. In modern JS, writing a => b => a + b is highly ergonomic. But in Go, manual currying quickly becomes a nightmare of nested func(interface{}) interface{} closures.

To make FFI feel 100% native, gopurs uses a Go AST parser compiled to WebAssembly that analyzes your .go FFI files on the fly.

You just write pure, idiomatic Go:

func Add(a int64, b int64) int64 {
    return a + b
}
Enter fullscreen mode Exit fullscreen mode

The Wasm parser reads your signature and automatically generates the perfect, highly-optimized bridging code to handle the uncurrying and type conversions under the hood. You get the full power of the massive Go ecosystem without the leaky inner workings of the compiler.

Conclusion: the mesomorphic paradigm 💎

We often talk about the compromise between high-level safety and low-level performance. With gopurs, the goal was to prove that this compromise is no longer necessary.

By preserving structural types via TAST and mapping functional concepts to native Go constructs (goroutines, generics), we achieved a mesomorphic approach: the solid, unshakeable safety of PureScript's type system, merged with the liquid, battle-tested execution power of Go's runtime.

In stress tests designed to push the compiler to its absolute limits, the generated code performs at roughly ~2x the execution time of code written entirely by hand in native Go. This is a massive victory. It means that in a real-world project, 99% of your codebase will perform remarkably close to native speeds. For the remaining 1% of critical bottlenecks, you can drop down to native Go FFI with zero friction.

And to prove that this is not just an experimental lab toy, gopurs now passes 100% of the official PureScript tests, as well as full integration tests with Postgres, S3, and RabbitMQ. It is strictly correct, and blazing fast.

Infinite

The quiet rewrite of the web is expanding beyond the browser, straight into the backend's raw metal.

Let's keep building.

👉 Check out the multi-runtime benchmark
👉 Follow my gopurs devlog
👉 Star gopurs if you like it

Top comments (0)