Building off of https://roc.zulipchat.com/#narrow/stream/304641-ideas/topic/map2-based.20record.20builders/near/446383062, I have some concerns and ideas for record builders I want to try and address.
In summary:
map2 proposal exists which might replace that syntax.While I think the proposed map2 syntax is a major step up from the existing syntax, it still has some problems from my perspective:
! and the pizza slice.So my rough proposal is to implement the sugar in a function call. In other words, a map2 based example may look like:
Task.concurrent <- {
users: Http.get "/users",
posts: Http.get "/posts",
news: Http.get "/news",
}
but converted to a function call, it may look like:
RecordBuilder.shrink {{
users: Http.get "/users",
posts: Http.get "/posts",
news: Http.get "/news",
}}
Task.concurrent
For this example, the first input to RecordBuilder.shrink use double-brackets ({{...}}). That's just my own way of signalling that this isn't a real record, but actually syntax sugar. But it can look many other ways too of course.
In this example, RecordBuilder.shrink will then return a
Task {
users: List User,
posts: List Post,
news: List News,
}
I've tried to boil down the essence of what "record builder" actually means, in terms of semantics. And I see it as representing the following steps:
I realise steps 3 and 4 are really just a single step in the real world, but I find it easier to explain with an analogy of "take this data, shrink it, do computing, and explode the result back out".
Requiring it be used with a function I think makes it easier to know when you can apply it (you have a type signature to look at) and also look up documentation for it. And the syntax sugar just produces a piece of data, which can only ever be given as input to RecordBuilder.shrink.
What then should the actual signature of RecordBuilder.shrink be? The {{...}} syntax should desugar into actual data, which has a type. I imagine something better-named-but-sort-of-like:
shrink : MagicalCompilerInternal, fnDoingMap2OnType a -> ...
Here the MagicalCompilerInternal type is what the syntax sugar desugares to, and fnDoingMap2OnType is just my clumsy way of specifying a map2 function. Note that I don't know how we would actually specify the return type, but it needs to be derived from what is contained in MagicalCompilerInternal at least.
And because the syntax sugar desugares into something with an actual type, which needs to be used with a regular function, it all fits well with existing concepts and operators in the language.
How would the compiler know which type variable of Task to extract List User from?
I know this is suggesting some fundamental changes to the type system, but I'm still struggling to imagine how shrink could be typed so that all the pieces are linked correctly
Let's say we added HKTs to type system, so we could type the fnDoingMap2OnType part of the shrink signature as:
f a, f b, (a, b -> c) -> f c
How would that be tied properly to MagicalCompilerInternal?
MagicalCompilerInternal and the unspecified internals of shrink definitely do some heavy lifting here, and I'm only thinking this through as I'm typing it now :smiley: There's two overall questions here as I see it:
shrink look such that the type of the output can be determined from the type of the input?I think the best answer to both of these questions is that they work close to what you specify in the map2 proposal.
So:
MagicalCompilerInternal generic over the output, such that the signature of shrink becomesshrink : MagicalCompilerInternal r, (f a, f b, (a, b -> c) -> f c) -> f r
Where r is the type of the output record.
{{
users: Http.get "/users",
posts: Http.get "/posts",
news: Http.get "/news",
}}
To desugar to something like
@MagicalCompilerInternal \map2Fn ->
map2Fn
(Http.get "/users")
(map2Fn
(Http.get "/posts")
(Http.get "/news")
\posts, news -> (posts, news)
)
\users, (posts, news) -> { users, posts, news }
In other words, MagicalCompilerInternal is really just a mostly normal opaque type, which shrink can unwrap, with a callback for shrink to execute. shrink just needs to pass it the map2 function the user provides.
Sorry if this has already been covered, but does this specify which fields need handled, or so they simply all get handled the same way? In the existing syntax I can do something like
{
a: 1,
b: <- foo 2
}
Do either of the new proposals handle partial field handling?
@Mythmon In my proposal, all the fields go through the same function, so If you want a value to end up as-is, you have to wrap with a function like Task.ok : a -> Task a _. This is a rare case, so we are optimizing for the most common case.
I'd recommend continuing that discussion in that topic, as this one is for an alternative way to define a builder.
I'm trying to compress this idea as much as possible. If I'm following correctly, what you're suggesting is:
We allow builtins (at least) to use HKTs
We add a RecordBuilder builtin using them:
module [RecordBuilder, shrink]
RecordBuilder r f a b := (f a, f b, (a, b -> c) -> f c) -> f r
shrink : RecordBuilder r f a b -> (f a, f b, (a, b -> c) -> f c) -> f r
shrink = \@RecordBuilder fn, map2 -> fn map2
map2 function:{{
users: Http.get "/users",
posts: Http.get "/posts",
news: Http.get "/news",
}}
We desugar it the same as I proposed here except that instead of inlining the map2 function, we produce a function that takes map2 as a parameter and then wrap that in the @RecordBuilder opaque.
We'd be creating an opaque from a different module, but we can cheat because we are the compiler.
The user can then define and call a builder like this:
RecordBuilder.shrink
{{
users: Http.get "/users",
posts: Http.get "/posts",
news: Http.get "/news",
}}
Task.concurrent
I don't think what I wrote there for RecordBuilder would work for a standalone builder (what would a and b be?). We probably need other type system features I don't know about.
Somebody smarter than me would have to tell you, I don't have much experience there yet
That's the gist of it, though I wonder why you think we need HKTs? Or maybe I'm missing something?
And for a standalone builder, couldn't a and b be inferred based on the types in the builder? :thinking:
HKTs are needed for the f bit in f a, f b…
Right, that's a generic too of course.
do I understand correctly that the idea here is to make record builders easier to learn by introducing higher-kinded polymorphism to the language? :face_with_raised_eyebrow:
Nah, HKTs are not the point, though I don't know if they can be worked around. The point is:
Again, I don't know if HKTs are actually required to achieve this, or if it can be done without them, but I think the downsides of the previous designs are worth trying to solve at least. I laid them out in the first post :smile:
Last updated: Jun 16 2026 at 16:19 UTC