rescript-lang / rescript-lang/rescript
Proposal: Native ReScript macros for extensions and derivers
Nobody has claimed this yet.
- Dominant language
- OCaml
- Stars
- 7.5k
- Forks
- 485
- Avg merge
- 1d 2h
- Merged PRs (30d)
- 55
Description
Update 26-04-10: Added the sequence diagram for extension and deriver.
Summary
I'd like to propose a native macro system for ReScript.
The goal is to provide a ReScript-native replacement path for custom extension and deriver workflows that currently rely on PPX-style rewriting. Instead of writing transforms in OCaml against the legacy PPX surface, macro authors would write handlers in ReScript and work against a structured public AST.
The proposed user-facing forms are:
- expression extensions:
%ns.name(...) - structure/signature extensions:
%%ns.name(...) - native derivers on type declarations:
@ns.nameand@ns.name(...)
Motivation
ReScript currently has extensibility through existing PPX-compatible infrastructure, but the authoring model is still inherited from the OCaml world.
That has a few drawbacks for ReScript users:
- custom transforms are not written in ReScript
- the public surface is not designed around ReScript ergonomics
- extension/deriver authoring feels disconnected from the rest of the toolchain
- it is harder to build a ReScript-first ecosystem of compile-time transforms
A native macro system would let us design this around ReScript itself.
Proposal
1. Project-level macro source set
Enable macros from rescript.json:
{
"name": "@foo/a",
"sources": {
"dir": "src",
"subdirs": true
},
"dependencies": ["ppxlib_res"],
"macros": {
"dir": "macros",
"namespace": "foo.a",
"node": "node"
}
}
Proposed fields:
- macros.dir
- macros.namespace
- macros.node
If namespace is omitted, it can default from the package name.
2. Macro registration in ReScript
Macro handlers live under macros/ and are registered with attributes on top-level let bindings:
@macro.extension("graphql")
let expand = (...) => ...
@macro.deriver("spice")
let derive = (...) => ...
3. ReScript-native call-site syntax
Example usage:
let message = %foo.a.graphql("spice")
%%foo.a.graphql("macroValue")
@foo.a.accessors
type user = {name: string}
@foo.a.spice
type drink = {label: string}
4. Structured public AST
Instead of a string-in/string-out protocol, macros should work against a structured public AST API, for example:
- PpxlibRes.Ast
- PpxlibRes.Builder
- PpxlibRes.Payload
- PpxlibRes.Extension
- PpxlibRes.Deriver
- PpxlibRes.Codec
Example extension API:
type ctx = {
filePath: string,
moduleName: string,
}
type context =
| Pexp
| Pstr
| Psig
type result =
| Pexp(Ast.expression)
| Pstr(array<Ast.structureItem>)
| Psig(array<Ast.signatureItem>)
Example deriver API:
type ctx = {
filePath: string,
moduleName: string,
}
type result = {
structure: array<Ast.structureItem>,
signature: array<Ast.signatureItem>,
}
Suggested handler shapes:
@macro.extension("graphql")
let expand = (
kind: Extension.context,
payload: Ast.payload,
ctx: Extension.ctx,
): Extension.result => ...
@macro.deriver("spice")
let derive = (
ctx: Deriver.ctx,
args: option<Ast.payload>,
typeDecls: array<Ast.typeDeclaration>,
): Deriver.result => ...
Intended Direction
This is meant to be the preferred model for new ReScript-authored transforms.
In other words:
- macro authors write ReScript, not OCaml
- the public API is designed for ReScript
- extension points and derivers use ReScript-native syntax
- this reduces the need to introduce new PPX-style rewriting layers for ReScript-specific use cases
Execution Model
A reasonable implementation model is:
- compile macro sources to JavaScript
- run them in Node.js during compilation
- serialize payloads/type declarations across the process boundary
- expose a structured AST API to macro authors
- decode the result back into compiler AST and continue compilation
- The transport format can be JSON, while the public API remains structured AST.
Initial Scope
A first version does not need the full compiler AST.
A useful subset would already cover many cases:
-
payloads
- PStr
- PSig
- PTyp
- PPat
-
expressions
- Pexp_ident
- Pexp_constant
- Pexp_apply
- Pexp_fun
- Pexp_construct
- Pexp_let
- Pexp_sequence
- Pexp_record
- Pexp_field
-
patterns
- Ppat_any
- Ppat_var
- Ppat_record
-
structure/signature items
- Pstr_eval
- Pstr_value
- Pstr_type
- Psig_value
- Psig_type
-
core types
- Ptyp_constr
- Ptyp_arrow
- Ptyp_tuple
Unsupported nodes can fail explicitly at the macro call site.
Sequence Diagrams
Extension
sequenceDiagram
participant Src as User source
participant Parser as Parser
participant NM as Native_macro
participant Node as Node runner
participant Macro as expand
Src->>Parser: Parse source module
Parser->>NM: Encounter expression extension
NM->>NM: Resolve macro registration
NM->>Node: request JSON
Node->>Macro: Call expand handler
Macro-->>Node: Return public AST result
Node-->>NM: response JSON
NM->>NM: Decode to compiler AST
NM-->>Parser: Replace original expression
Deriver
sequenceDiagram
participant Src as User source
participant Parser as Parser
participant NM as Native_macro
participant Node as Node runner
participant Macro as registered deriver
Src->>Parser: Parse source module
Parser->>NM: Encounter type declaration with deriver
NM->>NM: Resolve deriver registration
NM->>NM: Preserve original type and strip macro attrs
NM->>Node: request JSON
Node->>Macro: Call registered deriver handler
Macro-->>Node: Return generated structure and signature items
Node-->>NM: response JSON
NM->>NM: Decode to compiler AST
NM-->>Parser: Append generated items after original type group
Example Outcomes
A simple extension could turn:
let message = %foo.a.graphql("spice")
into something like:
let message = "query:" ++ "spice"
A simple deriver could turn:
@foo.a.spice
type drink = {label: string}
into generated bindings such as:
let encodeDrink: drink => JSON.t
let decodeDrink: JSON.t => drink
while preserving the original type declaration.
PoC
There is already a working reference implementation and design write-up in my fork:
- source code: https://github.com/mununki/rescript
- design document: https://github.com/mununki/rescript/blob/poc-macro/docs/macro_design.md
- PoC example: https://github.com/mununki/rescript-macro-poc
Follow-up Design Questions
The current PoC only covers project-local macros under macros/.
A future design step should define how macros can be published by npm packages and consumed through normal package dependencies. In practice, that means designing the interface for:
- exposing macro entry points from a package
- resolving macro namespaces from dependencies
- using dependency-provided macros from an application project
I think this is an important missing piece for a complete macro design, even though it is intentionally out of scope for the current prototype.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the PoC design document and compare its project-local macros/ layout and rescript.json fields with the compiler's current PPX and extension infrastructure. Trace the proposed Node.js request/response flow and structured AST across parsing and compilation. Done is not defined by a small patch here; the issue first needs an agreed initial scope and implementation plan.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, node.js, ocaml
- Domain
- build-system, compilers
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Needs clarification
- Newbie friendliness
- 25/100