Stream: ideas

Topic: Record builder as a function


view this post on Zulip Kasper Møller Andersen (Jun 24 2024 at 13:33):

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:

While I think the proposed map2 syntax is a major step up from the existing syntax, it still has some problems from my perspective:

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:

  1. Get some collection of named items/operations/whatever
  2. Combine those into a single thing ("shrink")
  3. Do something with the item which produces a result
  4. Unshrink/explode the result into the named parts that was specified in the input.

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.

view this post on Zulip Kasper Møller Andersen (Jun 24 2024 at 13:40):

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.

view this post on Zulip Agus Zubiaga (Jun 24 2024 at 14:00):

How would the compiler know which type variable of Task to extract List User from?

view this post on Zulip Agus Zubiaga (Jun 24 2024 at 14:03):

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

view this post on Zulip Agus Zubiaga (Jun 24 2024 at 14:11):

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?

view this post on Zulip Kasper Møller Andersen (Jun 24 2024 at 14:27):

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:

  1. How does the signature of shrink look such that the type of the output can be determined from the type of the input?
  2. How does the input actually encode the information needed to build the output?

I think the best answer to both of these questions is that they work close to what you specify in the map2 proposal.

So:

  1. I would probably make MagicalCompilerInternal generic over the output, such that the signature of shrink becomes
shrink : MagicalCompilerInternal r, (f a, f b, (a, b -> c) -> f c) -> f r

Where r is the type of the output record.

  1. Then I would implement the syntax sugar in the compiler a la
{{
    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.

view this post on Zulip Mythmon (Jun 24 2024 at 14:31):

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?

view this post on Zulip Agus Zubiaga (Jun 24 2024 at 14:41):

@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.

view this post on Zulip Agus Zubiaga (Jun 24 2024 at 15:19):

I'm trying to compress this idea as much as possible. If I'm following correctly, what you're suggesting is:

  1. We allow builtins (at least) to use HKTs

  2. 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
  1. There's new syntax to define a standalone builder without specifying the map2 function:
{{
    users: Http.get "/users",
    posts: Http.get "/posts",
    news: Http.get "/news",
}}
  1. 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.

  2. We'd be creating an opaque from a different module, but we can cheat because we are the compiler.

  3. 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

view this post on Zulip Agus Zubiaga (Jun 24 2024 at 15:29):

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.

view this post on Zulip Agus Zubiaga (Jun 24 2024 at 15:31):

Somebody smarter than me would have to tell you, I don't have much experience there yet

view this post on Zulip Kasper Møller Andersen (Jun 24 2024 at 17:02):

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:

view this post on Zulip Agus Zubiaga (Jun 24 2024 at 17:03):

HKTs are needed for the f bit in f a, f b…

view this post on Zulip Kasper Møller Andersen (Jun 24 2024 at 17:09):

Right, that's a generic too of course.

view this post on Zulip Richard Feldman (Jun 24 2024 at 17:22):

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:

view this post on Zulip Kasper Møller Andersen (Jun 24 2024 at 17:47):

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