Best Approach for Generating ReScript Builder Patterns with Custom Attributes

Hello ReScript community,

I want to perform code generation on simple types by developing a custom attribute. My goal is to generate the corresponding builder pattern for a given type by adding this attribute. I believe there’s a strong use case for this in scenarios like forms, where filling out a type one property at a time is cumbersome, even with make functions.

// src/User.res

@builder
type user = {
  name: string,
  age: int,
}

// Generated output would look like:
// src/generated/UserBuilder.res

type t = {
  name: option<string>,
  age: option<int>,
}

let empty = () => {
  name: None,
  age: None,
}

let withName = (builder: t, name: string) => {
  ...builder,
  name: Some(name),
}

let withAge = (builder: t, age: int) => {
  ...builder,
  age: Some(age),
}

// Build with validation
let build = (builder: t): result<User.user, string> => {
  switch (builder.name, builder.age) {
  | (Some(name), Some(age)) => 
      Ok({
        name,
        age,
      })
  | (None, _) => Error("User name is missing")
  | (_, None) => Error("User age is missing")
  }
}

// Usage example:
let user = UserBuilder.empty()
  ->UserBuilder.withName("John")
  ->UserBuilder.withAge(30)
  ->UserBuilder.build()

Based on this post’s response, I understand that working with PPXs is cautioned against because the AST evolves internally and could cause breakage.

The other option I’m aware of is dumping the AST via npx bsc -dparsetree MyFile.res and working with the generated dump file.

I’ve also considered a pure ReScript/regex solution, but that does not seem idiomatic to me.

What is the best approach for creating an attribute such as the @builder attribute that I hope to develop? Should I use PPX, work with the ReScript AST directly, or is there another recommended approach?

2 Likes

I often build some functions which will traverse my types at runtime and print them to stdout.
So I can run these scripts with node / bun and pipe the output to another file.

Here is a very simple example:

// Type Gen

let makeBuilder = (form) => {
  Console.log("type t = {")

  form->Obj.magic->Dict.forEachWithKey((field, fieldName) => {
    let dt = switch field {
      | JSON.String(_) => "option<string>"
      | JSON.Number(_) => "option<int>"
      // ...
    }

    Console.log(`  ${fieldName}: ${dt},`)
  })

  Console.log("}")
}

type form = {
  name: string,
  age: int,
}

makeBuilder({
  name: "", 
  age: 0,
})
// Result

type t = {
  name: option<string>,
  age: option<int>,
}

If the runtime type is not enough, you could also add some helper functions for the fields:

// Type Gen

let makeBuilder2 = (form) => {
  Console.log("type t = {")

  form->Obj.magic->Dict.forEachWithKey((field, fieldName) => {
    Console.log(`  ${fieldName}: ${field},`)
  })

  Console.log("}")
}

let string: string = Obj.magic("option<string>")
let int: int = Obj.magic("option<int>")
let float: float = Obj.magic("option<float>")

type form2 = {
  someString: string,
  someInt: int,
  someFloat: float,
}

makeBuilder2({
  someString: string, 
  someInt: int,
  someFloat: float,
})
// Result

type t = {
  someString: option<string>,
  someInt: option<int>,
  someFloat: option<float>,
}

Of course, you have to handle nested types, variants, etc, but just the idea.
I know, It’s a bit more boilerplate, but

  1. it’s simple rescript code
  2. it doesn’t need a complex toolchain, parsers, whatever

If you would run a small script (maybe a filewatcher) on a predefined folder, you could also split these types into different input and output files.

You can find a more complex example of my approach in my sql query builder.

1 Like

You could try npx rescript-tools doc MyFiles.res, that will dump typed tree information in JSON format.
I will most likely contain the info you need.

rescript-tools is included in v12, it is a separate npm package in older versions.

3 Likes

Thanks @nojaf I think this might be the right solution for me.

This is an intriguing solution. Thanks for putting in the time!