Experimental WIP. Typed code generation and the native FFI ecosystem are actively evolving.
An optimizing PureScript-to-F# compiler, written in PureScript, bringing PureScript's pure business logic to .NET, with its cross-platform runtime, threading facilities and library ecosystem. F# is the generated language; C# is also supported for FFI implementations.
sharpurs uses the enriched TAST / Typed CoreFn (tcorefn) produced by a custom PureScript compiler. This retains structural types, ADT layouts, type-class declarations and type applications for native code generation. In the current toolchain, that typed payload is written to output/<Module>/corefn.json. The filename does not make it equivalent to the type-erased CoreFn emitted by the upstream compiler.
F# provides functional programming, discriminated unions and pattern matching on the same runtime as C#. sharpurs aims to let PureScript developers keep their pure code and type system while using .NET for services, command-line tools and existing applications.
The backend generates an ordinary F# project that the .NET SDK can build and run. Native F# and C# bindings provide access to .NET APIs and NuGet libraries. Node.js runs the compiler; the generated application runs on .NET.
The F#/C# results in altbak.pub compare compiled PureScript with native F#/C# implementations on CPU and allocation workloads. The repository also records extended workloads covering I/O, mutability and async.
Use those documented baselines, workload definitions and execution conditions when assessing a change. Results vary substantially by algorithm and representation; an isolated timing does not establish a general advantage over V8 or another backend.
The ecosystem has evolved drastically, unlocking new architectural paradigms that make building a completely new .NET backend highly relevant today:
While previous native compilers were often written in Haskell and parsed raw CoreFn, sharpurs is written 100% in PureScript. It integrates directly with a TAST-aware fork of Arista's purescript-backend-optimizer. This allows the compiler to instantly benefit from classical optimizations such as aggressive inlining, constant folding, and normalized function bodies at the AST level before .NET generation. The compiler itself runs on Node.js, ensuring it remains fully accessible.
sharpurs selects native representations for .NET where proven: integer loops, integer arithmetic, direct function and constructor calls, and typed ADT workers. Validated ADT layouts become F# discriminated unions with native fields, heavily reducing boxing and garbage collection overhead. While it preserves boxed interoperability for unsupported shapes, this selective unboxing massively improves hot-path performance.
To achieve native execution speeds, sharpurs consumes an enriched tcorefn.json (Typed CoreFn). This custom format preserves the deep structural typing information (ann.type), ADT layouts (dataDecls), type-class layouts (classDecls), and exact type applications (TypeApp) that standard corefn strips away. This metadata allows the compiler to generate idiomatic, structurally typed F# code end-to-end.
A unique strength of sharpurs is that it allows both F# and C# FFI in the same project. Companion .fs files can expose native F# functions, while companion .cs files can expose static C# methods. The backend automatically discovers these bindings and generates the necessary curried wrappers and boxing conversions needed by PureScript under the hood, making FFI development feel 100% native and type-safe.
sharpurs aims to be fully aligned with the current v0.15+ PureScript ecosystem and targets modern .NET 8 (net8.0). It natively leverages modern .NET SDK tooling, generating complete, ready-to-run .fsproj projects that integrate perfectly with the C# ecosystem and NuGet libraries.
The sharpurs-aff package maps PureScript's asynchronous monad (Aff) directly to F# Async, .NET tasks, and cancellation tokens. This connects PureScript asynchronous code to .NET's powerful scheduling and I/O facilities, bringing true multi-core scaling for free to CPU-bound parallel workloads.
- Node.js and npm to install and run the compiler.
- Spago 0.93.x on
PATH; the optimizer checkout declares^0.93.45, and the package configurations use registry77.10.1. - A TAST-capable
pursfrom the custom compiler fork. The backend's npm development dependency uses thepurescript-npmwrapper. Ensure application builds select this compiler too. - The .NET 8 SDK, including F#, to build the generated
net8.0projects.dotnetmust be onPATH; installing only the runtime is insufficient for compilation. - Git and the local compiler/FFI checkouts described below. The development scripts use Bash.
The current spago.yaml references a local optimizer checkout and sibling library forks. Its npm prepare hook runs the compiler build, so a bare npm install --save-dev github:0x000000000000000000001/sharpurs is not a self-contained installation while those paths are required.
Use this layout for the checked-in configuration:
workspace/
βββ purescript-backend-optimizer-sharpurs/
βββ sharpurs/
βββ sharpurs/ # this compiler repository
βββ sharpurs-prelude/
βββ sharpurs-effect/
βββ sharpurs-console/
βββ sharpurs-st/
βββ sharpurs-unsafe-coerce/
βββ sharpurs-assert/
βββ ... # other forks listed in bin/pkg
βββ my-app/
From an empty workspace directory, clone the optimizer branch and backend:
git clone --branch edge-sharpurs \
https://github.com/0x000000000000000000001/purescript-backend-optimizer.git \
purescript-backend-optimizer-sharpurs
mkdir sharpurs
cd sharpurs
git clone https://github.com/0x000000000000000000001/sharpurs.git
cd sharpursClone the test runner's core package list from the compiler root. Run this snippet in Bash, since bin/pkg defines a Bash array:
source bin/pkg
for pkg in "${CORE_PACKAGES[@]}"; do
if [ ! -d "../sharpurs-$pkg" ]; then
git clone "https://github.com/0x000000000000000000001/sharpurs-$pkg.git" \
"../sharpurs-$pkg" || break
fi
doneWith the prerequisites and local paths in place, install and build:
npm install
# After subsequent compiler changes:
npm run buildnpm install runs prepare, which invokes npm run build. The build compiles the PureScript sources and bundles Main into bin/sharpurs.js for Node.js. The checked-in bin/sharpurs wrapper invokes that bundle with larger Node stack and heap limits.
There is currently no bin/setup or flake.nix in this repository. The clone steps above replace the old setup-script instructions.
Create my-app beside the compiler and library forks in the layout above. A minimal spago.yaml for a console application is:
package:
name: my-app
dependencies:
- prelude
- effect
- console
workspace:
packageSet:
registry: 77.10.1
extraPackages:
prelude:
path: "../sharpurs-prelude"
effect:
path: "../sharpurs-effect"
console:
path: "../sharpurs-console"
backend:
cmd: ../sharpurs/bin/sharpursAdd src/Main.purs:
module Main where
import Prelude
import Effect (Effect)
import Effect.Console (log)
main :: Effect Unit
main = log "Hello from PureScript on .NET!"Use sharpurs-* overrides for every dependency whose foreign implementation needs to run on .NET, including transitive dependencies. Packages containing only PureScript can generally remain on the official registry. The three overrides above cover this example, not a complete application ecosystem.
For a Git-based application configuration, an override can instead use:
prelude:
git: "https://github.com/0x000000000000000000001/sharpurs-prelude.git"
ref: "main"
dependencies: []Pin compatible revisions when maintaining an application. The sharpurs-hello-world checkout contains a broader local override configuration and examples mixing F# and C# FFI.
From the application root, with the TAST compiler on PATH:
spago build
dotnet run --project output/Main/Program.fsproj -c ReleaseThe configured backend reads the typed JSON from output/ and writes a complete project to output/Main/, even when the selected entry module has another name:
| Generated file | Purpose |
|---|---|
Sharpurs_Prelude.fs |
Shared runtime helpers and event-loop bookkeeping. |
<Module>.fs |
Generated PureScript module and F# FFI wrappers, in dependency order in the project. |
EntryPoint.fs |
Calls the selected module's main. |
Program.fsproj |
Executable F# project targeting net8.0. |
<Module>.cs and FFI.CSharp.csproj |
C# sources and referenced library project, when C# FFI is present. |
Directory.Build.props |
Separate intermediate build directories for the F# and C# projects. |
To compile without running:
dotnet build output/Main/Program.fsproj -c ReleaseRun the generated project with dotnet run; the backend does not emit a standalone main.fs script for dotnet fsi.
Invoke the backend from the application root after producing its typed output/:
../sharpurs/bin/sharpurs --main App.Main --ffi ffiThe equivalent backend arguments can be supplied through Spago:
spago build --backend-args "--main App.Main --ffi ffi"| Option | Behavior |
|---|---|
--main <Module> |
Selects the module whose main is called; defaults to Main. It does not select modules for entrypoint-based dead-code elimination. |
--ffi <Directory> |
Adds a directory to the FFI search paths. A companion file beside the original .purs source takes precedence. |
The shared argument parser also recognizes --bundle, --output, --rewrite-limit and --autoload-path, but this backend does not use them. Input is fixed to output/, generated files go to output/Main/, and the optimizer rewrite limit is currently 10000. There is no automatic discovery of every module exporting main.
Place the implementation beside the corresponding .purs file. For example, src/Hello.purs:
module Hello where
foreign import greeting :: String -> StringAn F# implementation in src/Hello.fs can use a typed parameter:
module Hello
let greeting (name: string) = "Hello " + name + " from F#!"Alternatively, src/Hello.cs can provide the same import:
namespace Hello;
public static class FFI
{
public static string greeting(string name)
{
return "Hello " + name + " from C#!";
}
}For C# bindings, the namespace matches the PureScript module name and the class is named FFI. The wrapper generator looks for public static methods, matching their names case-insensitively. For F#, it discovers top-level let definitions. Both generators infer arity from source text and insert curried obj wrappers, unbox calls and result boxing. They are lightweight source recognizers, not full F#/C# parsers; keep exported declarations simple, and use a small wrapper for complex native signatures.
An Effect implementation must defer its work until the effect is invoked. For example, the F# shape of String -> Effect Unit is:
let logMessage (message: string) =
box (fun (_: obj) -> printfn "%s" message; box null)The native wrapper does not infer PureScript effect semantics for you. Use the implementations in sharpurs-effect, sharpurs-console and sharpurs-aff as references for effects and callbacks.
If both companion files exist, .fs supplies the PureScript-facing wrappers and .cs is still compiled, so the F# code can call C# helpers explicitly. The backend also searches local and Spago package directories for foreign implementations.
Use normal NuGet and MSBuild project references for native dependencies. The generated project files are rewritten by each backend run, so maintain application-specific dependency configuration outside those generated files, or apply it as a repeatable post-generation step. C# FFI dependencies belong to the C# project as well when they are used there.
sharpurs-aff represents an asynchronous action as an F# workflow carrying cancellation and supervision state. Fibers use TaskCompletionSource, Async.StartWithContinuations and cancellation tokens; delays use Task.Delay, and parallel composition uses native asynchronous tasks.
Use non-blocking .NET I/O APIs in foreign implementations and await them through the native async machinery. Starting an Aff does not turn blocking I/O into asynchronous I/O, and CPU parallelism depends on the operations and scheduling involved.
Process lifetime is still an integration constraint: the generated runtime exposes EventLoopAdd, EventLoopDone and EventLoopWait, and the Aff package accounts for started fibers. However, the current EntryPoint.fs generator only starts and joins the thread that invokes main; it does not call EventLoopWait. A host running an application that launches background fibers must arrange to wait for their completion. Automatic draining of all pending Aff work is not currently guaranteed by the generated entrypoint.
The checked-in bin/pkg is the authoritative list of sibling packages required by bin/test. The runner creates its Spago configuration on first use, copies fixtures into tests/runner/src, compiles them, generates F#, and executes the generated project in Release mode.
From the compiler root:
# Run the vendored passing-test suite using the current backend bundle.
./bin/test
# Rebuild the backend, clean the runner caches, and run one fixture.
./bin/test TCO -cThe runner skips five fixtures requiring newer compiler features and stops at the first failure. Its result applies to this vendored suite and the selected toolchain; it is not a claim that every current upstream PureScript test or library is supported. The -c / --clean option clears the runner's .spago, output and output-es contents.
Focused regression commands are defined in package.json:
| Commands | Coverage |
|---|---|
npm run test:runtime |
Generic function application, FFI wrappers and exception boundaries. |
npm run test:kernel, npm run test:local-kernel |
Native integer loops and locally nested kernels. |
npm run test:adt-kernel, npm run test:adt-interop |
Typed ADT generation and boxed/native boundaries. |
npm run test:adt-unary, npm run test:adt-multi |
Native recursive ADT workers and multiple arguments. |
npm run test:int-comparison, npm run test:int-arithmetic |
Integer comparison, overflow, division and modulo behavior. |
npm run test:direct-call |
Saturated calls and preservation of the generic fallback. |
npm run test:thunk-kernel |
Selected typed thunk fusion and its exclusion cases. |
npm run test:constructor-typeapp |
Constructor calls with explicit and inferred type applications. |
Build the compiler first for tests importing its output/ modules. The runtime test reads source helpers directly and additionally needs sharpurs-exceptions. Tests that compile fixtures use the TAST purs and run generated F# through dotnet fsi; PURS=/path/to/purs and DOTNET=/path/to/dotnet select those executables in the focused scripts. The shell runner instead uses purs and dotnet through PATH.
- PureScript-to-F# code generation and executable .NET project generation.
- F# and C# foreign implementations, including mixed modules.
- TAST-aware integer, ADT, call and thunk optimizations with boxed interoperability.
- A vendored PureScript passing-test runner and focused runtime/code-generation regressions.
- An Aff implementation based on .NET async facilities and an alternate-library ecosystem.
- Integration with the
altbak.pubmulti-runtime benchmarks. - Broader coverage of native representations and optimizations.
- A self-contained installation workflow without local development checkouts.
- Reliable automatic waiting for pending Aff work in the generated entrypoint.
- Further FFI coverage, compiler cleanup and compatibility validation.
The project remains experimental. Contributions to the compiler, regression coverage, FFI libraries and application examples are welcome.
The main parts of the compilation pipeline are:
- Typed input: the custom
purscompiler emits enrichedcorefn.jsonfiles. The TAST-aware optimizer reader decodes them and sorts modules by dependencies. - Optimization and selection: the optimizer prepares
BackendModulevalues.Sharpurs.IntKernel,Sharpurs.AdtKernelandSharpurs.ThunkKernelselect supported typed transformations while retaining access to the source AST. - Code generation: Sharpurs.CodeGen combines those selections with the general generator and produces Sharpurs.FsAst values. Separate helpers recognize direct calls, instantiated constructors and integer operations.
- FFI and printing: Sharpurs.FfiSupport emits foreign wrappers; Sharpurs.Printer prints F# declarations.
- Project generation: Main writes the runtime, modules, entrypoint and .NET projects into
output/Main/.
The CLI compares generated text with existing files before writing, preserving timestamps when contents are unchanged. It currently returns no cached modules from the optimizer's skip hook and does not read or write an optimization cache. The earlier .sharpurs-cache.json description does not describe the current implementation.
MIT License. See LICENSE for details.