Roc's New Record Builder: Design & Implementation
Hey guys! Let's dive into the cool new design for Roc's record builder! This is all about making things cleaner and more intuitive. We're ditching the old syntax for something that feels more natural, especially if you're already familiar with how number literals work in Roc. Get ready to explore the ins and outs of this update, from the basic syntax to how it all works under the hood.
Overview
The main idea behind this change is to replace the current record builder syntax, which looks like { mapper <- field: value, ... }, with a suffix-based syntax: { field: value, ... }.TypeName. This new syntax mirrors how number literals work, like 42.Dec. Basically, you define your record, and then you tack on the type at the end. The important part is that this suffix needs to be a nominal type that has a map2 associated function. The Roc compiler then takes this and turns it into a series of map2 calls behind the scenes.
Current Syntax (Old Rust Compiler)
If you've been using Roc for a while, you might be familiar with the old record builder syntax. For reference, there's a guide here that shows you how it used to work. Here's an example of what the old syntax looked like:
cli_parser =
{
Cli.map2 <-
host: Cli.option({ long: "host", help: "Server hostname" }),
port: Cli.option({ long: "port", help: "Server port" }),
verbose: Cli.flag({ long: "verbose", help: "Enable verbose logging" }),
}
|> Cli.build
This syntax can be a bit clunky and hard to read, especially when you have a lot of fields in your record.
Proposed Syntax (New Zig Compiler -- To Be Implemented)
Now, let's check out the proposed new syntax, which is designed to be cleaner and more straightforward. It's all about that suffix, .TypeName, making everything read more naturally. Check it out:
cli_parser = {
host: Cli.option({ long: "host", help: "Server hostname" }),
port: Cli.option({ long: "port", help: "Server port" }),
verbose: Cli.flag({ long: "verbose", help: "Enable verbose logging" }),
}.Cli
See how much cleaner that is? You define the record with all its fields, and then you simply specify the type at the end with .Cli. Much easier on the eyes, right?
Core Design
Let's break down the core design principles behind this new record builder. It's all about making things efficient and easy to understand.
Suffix Requirements
Okay, so here's the deal: the suffix .T must refer to a nominal type. And this nominal type must have a map2 associated function. This map2 function is the heart of the record builder, and it needs to have a specific signature:
map2 : T(a), T(b), (a, b -> c) -> T(c)
What this means is that map2 takes two values of type T(a) and T(b), and a function that combines an a and a b into a c. It then returns a T(c). The compiler uses static dispatch to find T.map2 and generates the code accordingly.
Desugaring
This is where the magic happens. The compiler takes your record builder syntax and transforms it into a series of map2 calls. Let's look at how this works with a couple of examples.
Two fields
When you have a record builder with two fields, it gets desugared into a single map2 call:
{ a: fa, b: fb }.T
becomes:
T.map2(fa, fb, |a, b| { a, b })
So, fa and fb are passed as the first two arguments to T.map2, and a lambda function |a, b| { a, b } is used to combine the results into a new record { a, b }.
Three or more fields
Now, if you have three or more fields, the desugaring becomes a chain of map2 calls, using tuples as intermediate values:
{ a: fa, b: fb, c: fc }.T
becomes:
T.map2(
fa,
T.map2(fb, fc, |b, c| (b, c)),
|a, (b, c)| { a, b, c }
)
In this case, fb and fc are first combined into a tuple (b, c) using T.map2. Then, fa is combined with the tuple (b, c) using another T.map2 call, resulting in the final record { a, b, c }.
The order in which fields are combined is important! Fields are combined in declaration order—that's left-to-right and top-to-bottom.
Ignored Fields
Sometimes, you might want to evaluate an expression for its side effects but not include it in the final record. That's where ignored fields come in. You can prefix a field with _ to indicate that it should be ignored. Anonymous _: syntax is also supported for even cleaner code:
{
_: log_start!(),
result: compute!(),
_: log_end!(),
}.Task
In this example, log_start!() and log_end!() are evaluated for their side effects (presumably logging some information), but they are not included in the Task record.
Constraints
There are a couple of important constraints to keep in mind when using the new record builder:
- Minimum 2 fields: You need at least two fields in your record builder. Single-field records aren't valid Roc grammar.
- All fields must have the same wrapper type: All expressions in the record builder must return
T(a)for the sameT. This ensures that themap2calls can be chained together correctly.
Example: CLI Argument Parsing
Let's see how this new record builder can be used in a real-world example: building a CLI argument parser. A CLI parser takes command-line arguments and produces a parsed value. The type parameter a represents what type the parser produces when run. This example really shows the power and elegance of the new syntax.
# Cli.roc
Cli(a) := {
parse: List(Str) -> Try(a, CliErr),
help: Str,
}.{
map2 : Cli(a), Cli(b), (a, b -> c) -> Cli(c)
map2 = |Cli(ca), Cli(cb), f|
Cli({
parse: |args|
a = ca.parse(args)?
b = cb.parse(args)?
Ok(f(a, b)),
help: Str.concat(ca.help, cb.help),
})
option : { long: Str, help: Str } -> Cli(Str)
option = |config|
Cli({
parse: |args| find_option(args, config.long),
help: " --$(config.long) $(config.help)\n",
})
flag : { long: Str, help: Str } -> Cli(Bool)
flag = |config|
Cli({
parse: |args| Ok(List.contains(args, "--$(config.long)")), help: " --$(config.long) $(config.help)\n",
})
}
In this Cli module, Cli(a) represents a CLI parser that produces a value of type a. The map2 function combines two Cli parsers into a new parser that produces a value of type c. The option and flag functions create Cli parsers for command-line options and flags, respectively.
Now, let's use this Cli module to define a CLI parser for our application:
import Cli
cli_parser : Cli({ host: Str, port: Str, verbose: Bool })
cli_parser = {
host: Cli.option({ long: "host", help: "Server hostname" }),
port: Cli.option({ long: "port", help: "Server port" }),
verbose: Cli.flag({ long: "verbose", help: "Enable verbose logging" }),
}.Cli
Each Cli.option(...) returns Cli(Str) and Cli.flag(...) returns Cli(Bool). The record builder combines them into a Cli({ host: Str, port: Str, verbose: Bool }).
The desugaring of this code produces the following:
cli_parser =
Cli.map2(
Cli.option({ long: "host", help: "Server hostname" }),
Cli.map2(
Cli.option({ long: "port", help: "Server port" }),
Cli.flag({ long: "verbose", help: "Enable verbose logging" }),
|port, verbose| (port, verbose)
),
|host, (port, verbose)| { host, port, verbose }
)
As you can see, the record builder syntax is desugared into a series of nested Cli.map2 calls. This allows you to easily combine multiple Cli parsers into a single parser for your application.
So, there you have it! The new record builder design in Roc is all about making things cleaner, more intuitive, and more efficient. With the new suffix-based syntax and the power of map2, you can build complex records with ease. I hope this explanation was helpful, and happy coding, folks!