Following up on the recent discussions in the WebAssembly and Go backend threads (along with local experimental WIPs: purust, sharpurs, javapurs…), I wanted to open a dedicated topic specifically for TASTs (Typed Abstract Syntax Trees). It feels like a crucial architectural point for AOT (Ahead-Of-Time) backends, and it deserves its own space.
The AOT compilation challenge
As many backend authors have experienced, compiling PureScript to strictly-typed or low-level languages (Go, C++…) presents a major challenge: the official PureScript compiler (purs) erases all type information at the value level before generating the corefn.json AST. While this is perfectly fine for dynamic languages like JavaScript, it forces AOT backends to either guess or rely on dynamic structures (like generic records or boxed values) for things like type class dictionaries and ADTs, leading to overhead and excessive allocations.
To solve this, I’ve been working on a custom compiler fork that generates an enriched Typed CoreFn (tcorefn.json). The goal is to provide backends with the exact memory layouts upfront and deep structural types at every AST node, enabling true zero-cost abstractions, static dispatch, and strict native structs. The performance gains are massive.
To understand why tcorefn was created and how it evolved to its current mature state, let’s look at how a simple module defining an ADT, a typeclass, and a constrained function is represented across the different compiler stages, for the following PureScript code excerpt (I simplified a bit, but note that they are scrollable):
module Example.Main where
data Maybe a = Nothing | Just a
class Eq a where
eq :: a -> a -> Boolean
class Eq a <= Ord a where
compare :: a -> a -> Ordering
isEqual :: forall a. Eq a => a -> a -> Boolean
isEqual x y = eq x y
isJust :: forall a. Maybe a -> Boolean
isJust = maybe false (const true)
foreign import identity :: forall a. { identity :: a } -> a
1. Standard CoreFn (the official PureScript AST)
In the standard JIT-focused compiler, types are completely erased at the value level. The backend receives a raw, untyped AST where type classes are just generic variables, and the memory layout is completely unknown.
{
"decls": [
{
"bindType": "NonRec",
"identifier": "isEqual",
"annotation": {
"sourceSpan": { "start": [1, 1], "end": [50, 1], "name": "src/Example/Main.purs" },
"meta": null
// PROBLEM: Type is completely erased! No `ann.type`.
// The AOT backend has no idea what this function takes or returns.
},
"expression": {
"type": "Abs",
"argument": "dict" // The dictionary is passed blindly
}
},
{
"bindType": "NonRec",
"identifier": "isJust",
"annotation": {
"sourceSpan": { "start": [14, 1], "end": [15, 34] },
"meta": null
},
"expression": {
"type": "App",
"abstraction": { "type": "Var", "value": { "identifier": "maybe" } },
"argument": { "type": "Literal", "value": false }
}
}
],
"foreign": ["identity"]
}
2. TAST v1
To solve the type erasure, I introduced tcorefn which injected the deep structural type at every node (ann.type) and added dataDecls to expose the ADT memory layouts. However, constraints were aggressively desugared by the compiler into standard Records, destroying the high-level semantic information.
{
// ADDED: `dataDecls` array exposing memory layouts for ADTs upfront
"dataDecls": [
{
"typeName": "Maybe",
"typeVars": ["a"],
"constructors": [
{
"constructorName": "Nothing",
"fieldTypes": []
},
{
"constructorName": "Just",
"fieldTypes": [
{ "TypeVar": "a" }
]
}
]
}
],
"foreign": ["identity"],
// ADDED: `foreignAnnotations` with deep structural types for FFI
"foreignAnnotations": {
"identity": {
"meta": { "metaType": "IsForeign" },
"type": {
"Func": {
// PROBLEM: Rows were flat objects, losing order and tails!
"args": [ { "Record": { "identity": { "TypeVar": "a" } } } ],
"ret": { "TypeVar": "a" }
}
}
}
},
"decls": [
{
"identifier": "isEqual",
"annotation": {
// ADDED: `type` information is now attached at the declaration level!
"type": {
"Func": {
"args": [
{
"Record": { // PROBLEM: The 'Eq a' constraint was aggressively desugared into a Record!
"eq": { "Func": { "args": [{"TypeVar": "a"}, {"TypeVar": "a"}], "ret": "Boolean" } }
}
},
{ "TypeVar": "a" },
{ "TypeVar": "a" }
],
"ret": "Boolean" // PROBLEM: Missing ForAll quantifiers
}
}
}
},
{
"identifier": "isJust",
"annotation": {
// ADDED: `type` attached here too
"type": { "Func": { "args": [ { "ADT": { "path": ["Data", "Maybe", "Maybe"], "args": [ { "TypeVar": "a" } ] } } ], "ret": "Boolean" } }
},
"expression": {
"type": "App",
"annotation": {
// ADDED: Types were successfully attached deep down at the expression level!
"type": { "Func": { "args": [ { "ADT": { "path": ["Data", "Maybe", "Maybe"], "args": [ { "TypeVar": "a" } ] } } ], "ret": "Boolean" } }
},
"abstraction": { "type": "Var", "value": { "identifier": "maybe" } },
"argument": { "type": "Literal", "value": false }
}
}
]
}
3. TAST v2
Following the latest updates, the format has been completely normalized. ConstrainedType is preserved, quantifiers are explicit, and we now export classDecls globally (just like dataDecls) so backends can generate strict native structs (like type Eq struct {...} in Go or Rust).
{
"dataDecls": [
{
// MODIFIED: Keys renamed and format normalized (`typeName` -> `name`, etc.)
// MODIFIED: Types are now extensible JSON objects (e.g. { "type": "TypeVar", "name": "a" })
"name": "Maybe",
"vars": ["a"],
"constructors": [
{
"name": "Nothing",
"fields": []
},
{
"name": "Just",
"fields": [
{ "type": "TypeVar", "name": "a" }
]
}
]
}
],
// ADDED: The exact shape of all type classes is exported globally!
"classDecls": [
{
"name": "Eq",
"vars": ["a"],
"superclasses": [],
"methods": [
{
"name": "eq",
"type": {
"type": "Func",
"args": [ { "type": "TypeVar", "name": "a" }, { "type": "TypeVar", "name": "a" } ],
"ret": { "type": "Boolean" }
}
}
]
},
{
"name": "Ord",
"vars": ["a"],
"superclasses": [
{
"fqn": ["Data", "Eq", "Eq"],
"args": [ { "type": "TypeVar", "name": "a" } ]
}
],
"methods": [
{
"name": "compare",
"type": {
"type": "Func",
"args": [ { "type": "TypeVar", "name": "a" }, { "type": "TypeVar", "name": "a" } ],
"ret": {
"type": "Adt",
"fqn": ["Data", "Ordering", "Ordering"],
"args": []
}
}
}
]
}
],
"foreign": ["identity"],
"foreignAnnotations": {
"identity": {
"meta": { "metaType": "IsForeign" },
"type": {
// ADDED: Explicit ForAll quantifiers
"type": "ForAll",
"vars": ["a"],
"body": {
"type": "Func",
"args": [
{
"type": "Record",
"row": {
// MODIFIED: Rows now properly preserve label ordering and tail variables!
"type": "Row",
"fields": [
{ "label": "identity", "type": { "type": "TypeVar", "name": "a" } }
],
"tail": null // ADDED: Tail variables are now supported
}
}
],
"ret": { "type": "TypeVar", "name": "a" }
}
}
}
},
"decls": [
{
"identifier": "isEqual",
"annotation": {
"type": {
// ADDED: Explicit quantifiers
"type": "ForAll",
"vars": ["a"],
"body": {
// MODIFIED: Semantic constraints are preserved! (No longer aggressively desugared to Record)
"type": "ConstrainedType",
"constraints": [
{
"fqn": ["Data", "Eq", "Eq"],
"args": [ { "type": "TypeVar", "name": "a" } ]
}
],
"body": {
"type": "Func",
"args": [
{ "type": "TypeVar", "name": "a" },
{ "type": "TypeVar", "name": "a" }
],
"ret": { "type": "Boolean" }
}
}
}
}
},
{
"identifier": "isJust",
"annotation": {
"type": { "type": "Func", "args": [ ... ], "ret": { "type": "Boolean" } }
},
"expression": {
"type": "App",
"annotation": {
// MODIFIED: Types are now extensible objects ({ "type": "Func", ... }) instead of { "Func": ... }
"type": {
"type": "Func",
"args": [ { "type": "Adt", "fqn": ["Data", "Maybe", "Maybe"], "args": [ { "type": "TypeVar", "name": "a" } ] } ],
"ret": { "type": "Boolean" }
}
},
"abstraction": { ... },
"argument": { ... }
}
}
]
}
Future Considerations (TAST v3: AI/LLMs and Minification)
While TAST v2 is tailored for mechanical AOT backends, it’s worth noting that it still strips away some high-level semantic intent that could be incredibly valuable for AI coding assistants and static analysis tools. For instance, docstrings and type synonyms (type UserId = String) are currently expanded or lost. Down the road, we might want to consider a v3 that explicitly preserves these (e.g. typeSynonymDecls, attached docstrings) to make the format perfectly AI-friendly and semantically complete.
Additionally, a major focus for v3 would be structural optimization and minification to drastically reduce the sheer size of the emitted JSON files (since preserving deep structural types at every single node may inflate the payload).
This future-proofing is exactly why almost every node in the TAST v2 is explicitly structured as an extensible JSON object (e.g., { "type": "...", ... }) rather than flat arrays or primitives: it allows us to seamlessly bolt on new metadata fields (like docstrings, spans, or semantic flags) later without breaking backwards compatibility for existing parsers. But for now, v2 solves the primary AOT memory layout challenge.
@harryprayiv Could you take a look at the structure and verify if it addresses the points you raised (like quantifiers, row ordering, and encoding consistency)? Regarding your concern about serializing post-elaboration types: TAST v2 actively avoids freezing the compiler’s internal AST by introducing a stable intermediate API (CoreFnType). This safely sanitizes internal constructs (e.g., automatically flattening Skolems and Unknowns into standard TypeVars) before they ever reach the JSON. Any structural changes to this format have a heavy impact on the architecture of all the backends depending on it (like gopurs and others), so it would be highly beneficial if we could align and iron out the details now to avoid too much back-and-forth later on.
(I know you highly value this kind of collaborative alignment, which I really appreciate!).
On my end, I am currently working on a final round of optimizations for gopurs that will fully take advantage of the very recent addition of classDecls, to replace dynamic dictionary records with strictly typed native Go structs.
For those interested in testing this out or seeing the implementation, all the changes discussed above are currently live on my compiler fork here.
If there is a consensus among AOT backend authors that this TAST v2 format is the right way forward, I would be more than happy to clean this up and submit it as an official PR to the PureScript compiler (option tcorefn, added to the available options for purs compile --codegen, among corefn , docs , js…). As the format is still actively evolving alongside downstream compilers (like gopurs), it might be a bit too early to open a PR just yet, but that is the ultimate goal in the coming weeks.
Cheers!
Edit: I initially forgot to include record/foreign examples. I added them.