I'm working on a few things that would benefit from Proposal: Record Builder Syntax. In my opinion, this would make Roc stand out from other FP languages where applicatives are often leveraged but feel somewhat clunky.
I already looked into how this could be added, and I would love to take a stab at implementing it.
@Richard Feldman Is this at a stage where you'd consider accepting a PR or is it too early for that?
totally happy to accept a contribution for it! :smiley:
the only question mark in my mind is whether we should have it desugar to apply
or map2
(each can be implemented in terms of the other, but I haven't thought through the pros/cons of choosing one vs the other) but that's the very last step of the feature, so I don't think it's a blocker for getting started
Interesting. If we desugared to map2
the "sugared" source would be different, right?
usersAndPosts = Task.collect {
users <- Http.get "/users" |> Task.batch,
posts <- Http.get "/posts" |> Task.batch,
}
In the example, apply
is provided as Task.batch
.
hm yeah, true
yeah maybe map2
doesn't make sense :stuck_out_tongue:
it just occurred to me recently that it was an option because it can be losslessly transformed to apply
, but thinking about it some more, it doesn't really seem to have any upsides here!
so let's stick with apply
Yeah, apply
seems more straightforward for this use case.
Ok, cool. I'll get started whenever I get some free time :smiley:
awesome, feel free to post here if you have any questions!
I'm not sure if this is the place for it but I just read the design doc for this feature and left wondering if there isn't something even better we could figure out without the downsides listed.
for instance, does it make sense to use anything other than Task.batch
for each of the items? I would think that Task.await
would make it sequential - though it wouldn't right? since one of the design goals of this syntax is to make people understand they can't use ordering.
it seems like this approach might make people have a headache trying to compute values inside the same collect
statement, even if it doesn't work it kinda looks enough like backpassing for people to think it would work...
with that in mind, any computed values or sequential tasks would need to happen in a follow-up step, right? wouldn't it be better to restrict it more and people could use a Task.map
if they wanted to compute something or Task.andThen
if they wanted to do something after all requests are done.
something like
Task.batch {
users: Http.get "/users",
posts: Http.get "/posts"
}
the syntax sugar would be for the function Task.batch
itself - it requires a record of tasks.
IIUC, Task.await
would fail to typecheck.
Related to his above question, why do we need Task.collect
at all. We should even be able to do:
usersAndPosts = Task.succeed {
users <- Http.get "/users" |> Task.batch,
posts <- Http.get "/posts" |> Task.batch,
}
It could desugar to:
Task.succeed (\users -> \posts -> { users, posts })
|> Task.batch (Http.get "/users")
|> Task.batch (Http.get "/posts")
I am not sure what adding in an extra layer of indirection and wrap
function does for us.
Alos, @Georges Boris, your above proposal would have no way to distinguish from a regular record without blessing Task.batch
, it needs to at least keep something like <-
so that we know to apply syntax sugar.
so would this syntax work for any other record constructor? from the doc I thought it was explicitly related to parallel tasks - so this would also be useful for something like this?
D.succeed {
name <- D.field "name" D.string |> D.required,
email <- D.field "email" D.string |> D.optional
}
(trying to mimick elm's json decode pipeline api)
Interesting. I mean it would build:
D.succeed (\name -> \email -> {name, email}
|> D.required (D.field "name" D.string)
|> D.optional (D.field "email" D.string)
the type of required and optional would be something like:
required: D a err, D (a -> b) err, D b err
optional: D a err, D (a -> b) err, D (Option b) []
Where optional would on failure return None
and clear out the error. While required would propagate the error.
I think all the types could work out at a minimum.
@Georges Boris Yes, it would work for any applicative API. For example, I want to use it to build type-safe SQL selections. You could also use it to build a CLI arguments parser.
You would just need something that can actually execute D
just like we have platforms executing Task
. So I think yes, it would enable that.
@Brendan Hansknecht Good point about not needing Task.collect
Oh, but D.string
would not really work. But some other api should work. As in, D.string
could work, but then email
would not be a Str
instead it would be a wrapped [String Str]
. So you would then have to match on the type even though you know the only type it could be. I think instead you would need D.fieldStr
and same for other types to make the api play nicely with types in roc.
it seems like a nice API that will need a veeery careful error message setup. I can totally see someone getting mixed up with types when trying to mix something like Task.batch
and Task.await
since the dependency is not as clear as an actual pipeline.
(optional
actually takes a default value in Elm - my mistake!)
Will be interesting to see how this works with Decode
in the future. I would assume it would in the end be built on top of Decode
, but maybe that is wrong. Not sure if you can currently decode with a default value. I think not.
Georges Boris said:
it seems like a nice API that will need a veeery careful error message setup. I can totally see someone getting mixed up with types when trying to mix something like
Task.batch
andTask.await
since the dependency is not as clear as an actual pipeline.
That's true. On the other hand, by having a special syntax for applicatives, we can produce much more insightful error messages than the other languages.
Brendan Hansknecht said:
Related to his above question, why do we need
Task.collect
at all. We should even be able to do:usersAndPosts = Task.succeed { users <- Http.get "/users" |> Task.batch, posts <- Http.get "/posts" |> Task.batch, }
It could desugar to:
Task.succeed (\users -> \posts -> { users, posts }) |> Task.batch (Http.get "/users") |> Task.batch (Http.get "/posts")
I am not sure what adding in an extra layer of indirection and
wrap
function does for us.
This is a good point. I can't come up with anything the layer of indirection would allow us to do, that we couldn't without it.
Other than having a more intentional name for it I guess.
the reason for Task.collect
is that it makes the sugar a self-contained expression
in other words, with Task.collect
, the sugar is all inside the curly braces and everything outside the curly braces is normal Roc
if you want Task.succeed { ... }
to replace Task.collect { ... }
then you need to say that, despite all appearances, that Task.succeed { ... }
is not a function call
rather it's the beginning of some syntax sugar that you only find out is happening if you read what's inside the curly braces
and every other time you see Task.succeed { ... }
you know that it's going to evaluate to a Task { ...some fields... }
but now it might not
I think calling Task.collect
any different/an actual function call is wrong. Task.collect
doesn't even make sense to be called with those args. (As in either way it is not a true function call and is a rule that a roc user needs to know. If they look at the raw Task.collect
, or the raw Task.succeed
neither truly makes senses. I think Task.succeed
reads closer to the expected output type.)
every other time you see
Task.succeed { ... }
you know that it's going to evaluate to aTask { ...some fields... }
but now it might not
This is still true. They syntax sugar always generates a record.
Brendan Hansknecht said:
Related to his above question, why do we need
Task.collect
at all. We should even be able to do:usersAndPosts = Task.succeed { users <- Http.get "/users" |> Task.batch, posts <- Http.get "/posts" |> Task.batch, }
It could desugar to:
Task.succeed (\users -> \posts -> { users, posts }) |> Task.batch (Http.get "/users") |> Task.batch (Http.get "/posts")
if you desugar the |>
s out, the Task.batch
calls are the outermost calls.
in other words, in this example we started with Task.succeed { ... }
and ended up with (after desugaring) Task.batch (Task.batch (Task.succeed ...)))
I personally like the parallel between:
Task.succeed {
user: getUser someData
email: loadEmail a b c
}
and:
Task.succeed {
users <- Http.get "/users" |> Task.batch,
posts <- Http.get "/posts" |> Task.batch,
}
Both give you the same output Task {user: Str, email: Str} err
, but one is in parallel and using sub tasks.
that's a compelling point!
:thinking: I guess |>
itself is actually precedent for adding wrapping calls to an expression after the fact
Of course, I think it will confuse users that some reason they have to transform to this syntax if there are dependencies:
users <- Http.get "/users" |> Task.await
posts <- Http.getPosts users "/posts" |> Task.await
Task.succeed {users, posts}
so it's already not necessarily the case that if you see Task.succeed { ... }
you know that a Task.succeed
will be the first call there; you already have to keep reading to see if there's a |>
coming in order to know what will be evaluated first
and actually I guess that's true of binary operators in general, not just |>
:big_smile:
Oh, another expansion to the syntax, could it also support this:
users <-getUsers |> Task.await
Task.succeed {
users,
emails <-getEmails users |> Task.batch,
posts <-getPosts users |> Task.batch,
}
Yes! That was going to be my next question.
I suppose, although at least one of them needs to be <-
:sweat_smile:
wait, actually I don't think that works
because it's not a |> Task.batch
I definitely do not think we should allow mixing and matching await
and batch
It should work, users
in that scope is already the final value, not the Task
oh wait nm I misunderstood
so users
is sugar for users: users
as usual, not users <- users
:thumbs_up:
yeah that makes sense!
in that case we should allow users: blah
as well (I forget if the doc mentioned that)
cool, I'm on board with both of those changes :thumbs_up:
For sure! When I was looking into how I would implement this earlier, I thought we can split the fields set with :
(or LabelOnly
) from those using <-
.
The former are kept the same, and the latter get desugared into the pipeline thing.
Yes, exactly what I was thinking
could be useful to have a default type alias for applicatives so it is easier to spot which functions are compatible with this feature than looking for the usually confusing type signature... type alias is definitely not the way to go but would be great to spot compatibility easily.
the pattern is:Task a err -> Task (a -> b) err -> Task somethingWithB err
So could be:
ApplicativeTask a b err : Task a err -> Task (a -> b) err
batch: ApplicativeTask a b err -> Task somethingWithB err
Not sure if it helps, but maybe...idk
I think we overlooked something:
Task.succeed {
users <- Http.get "/users" |> Task.batch,
posts <- Http.get "/posts" |> Task.batch,
}
could not desugar to:
Task.succeed (\users -> \posts -> { users, posts })
|> Task.batch (Http.get "/users")
|> Task.batch (Http.get "/posts")
because we would be applying Task.batch
with 2 arguments: Task.succeed (\users -> \posts -> { users, posts })
and (Http.get "/users")
It would work in Elm because Task.batch
would be partially applied with Http.get ..
and then the pipe would apply Task.succeed (..)
Since batch is Task a err -> Task (a -> b) err -> Task b err
, it is manually curried.
I think you just need extra parens:
Task.succeed (\users -> \posts -> { users, posts })
|> (Task.batch (Http.get "/users"))
|> (Task.batch (Http.get "/posts"))
Oh, parens do that? Let me check
CleanShot-2023-05-02-at-22.59.482x.png
I think |>
may not work correctly, but something like this should definitely type check:
batch1 = Task.batch (Http.get "/users")
batch2 = Task.batch (Http.get "/posts")
Task.succeed (\users -> \posts -> { users, posts })
|> batch1
|> batch2
Yeah, somehow we need to force application. Not sure if we have a way to do that in roc. Interesting issue.
Right. We could desugar to:
(Task.batch (Http.get "/posts"))
(
(Task.batch (Http.get "/users"))
(Task.succeed (\users -> \posts -> { users, posts }))
)
I think this may be a bug in |>
, but I could see an argument for either. Personally, I think the parens should cause execution even in a pipeline cause that is what happens when desugared like above.
Cause the |>
should desugar to what you wrote above.
Yeah, interesting. I guess it could do that, I'm not sure if I'd expect it to, the way I would in Elm with partial application.
Yeah, I'm not sure either, but then it leaves an open question, how do I apply a manually curried function in a pipeline? Feels weird to say it is impossible
I mean I guess you could wrap it |> \x -> (manuallyCurried someParam) x
I still have like 10 TODOs but this thing is working!
CleanShot-2023-05-03-at-21.48.502x.png
Also formatting:
CleanShot-2023-05-03-at-21.52.33.gif
wowwwwwww, this is amazing!
Oh wow...fast
would it be too crazy if the formatter transformed the non-sugared versions into this syntax for consistency? it would also prevent the errors this syntax is trying to help with :nerd:
Hm, interesting. I think it'd be hard to detect in a way that wouldn't trigger for other constructs that might look the same (AST wise) but are not applicatives at all.
Also, IMHO that'd just be too surprising
To me, that'd be similar to having the formatter rewrite a (b c))
to c |> b |> a
. I wouldn't want it to do that.
I’m pretty busy after work this week, so I’m unlikely to continue coding this until next week. However, I’d like to determine all the cases where we want to allow record builders in.
This is what I have so far:
# 1. Applied to a function identifier
succeed {
a: 123,
b <- apply getB,
c <- apply getC,
d,
}
# 2. Applied to tag
Succeed {
a <- apply getA,
b <- apply getB,
}
# 3. Applied to opaque wrapper
@Opaque {
a <- apply getA,
b <- apply getB,
}
# 4. Piped
{
a <- apply getA,
b <- apply getB,
}
|> succeed
# 5. Applied next to other arguments
succeed 456 {
a <- apply getA,
b <- apply getB,
}
# 6. Applied to an expression that returns a function
(getSucceed somehow) {
a <- apply getA,
b <- apply getB,
}
# 7. Record update
succeed {
recordToUpdate &
a <- apply getA,
b <- apply getB,
c: 123,
d
}
# 8. Record builder in a def
builder =
{
a <- apply getA,
b <- apply getB,
}
succeed builder
# 9. Mulitple record builders applied to the same function
succeed
{
a <- apply getA,
b <- apply getB,
}
{
z <- apply getZ,
}
Please let me know what you think. Let’s use the numbers to talk about each case.
My gut feeling is 1,2,3 and nothing else. It should only apply to functions directly.
4 is not direct.
5... I am really not sure would need to think about use cases
6 Probably not. We already pointed out that (func arg)
does not necessarily apply a function in roc. I think it just also would stick out weirdly and be uncommon.
7 Shouldn't be supported
8 Shouldn't be supported
9 Shouldn't be supported
very cool! :smiley:
I hadn't even thought about making this work for all the things that would be valid in front of a normal record literal (e.g. 2, 3, 4, 5) but I like that if someone tries to do that, it can Just Work the same way as any other expression would :thumbs_up:
I think we definitely should not support record updates (7) at least at first; if there's demand for it in practice, we can have a separate discussion about it and decide later whether to add it
also there have been discussions in the past about potential changes to record update syntax, so committing to it would potentially complicate those discussions unnecessarily
I like 8 being a compiler error; I could see an argument for it returning a partially-applied function, but I suspect that would be more surprising/confusing than useful.
I'm not even sure if there's a design where 9 could work, so :thumbs_up: to compile error there too :big_smile:
I think if 8 doesn't work, 4 shouldn't work. They are the same code, but one is explicit with the name and the other implicit
hm, my main concern with 8 is that you can pass builder
around, put it into the repl (what would its type be?), etc.
and those don't really make sense unless you define it to be a partial application or something
hm, actually maybe that idea is worth exploring :thinking:
eh nm, that doesn't make sense to me
I'm also ok with 4 being disallowed for now, and then we can revisit later
Also, should (someFunc someArg)
apply a function in Roc? Currently that isn't consistent due to pipelining which ignores parens.
Cause that is what makes me unsure about 6
hm, what's an example of pipelining ignoring parens in that context?
x
|> someFunc y
# becomes
someFunc x y
# and this
x
|> (someFunc y)
# becomes
(someFunc x y)
# So you have to write
x
|> \n -> (someFunc y) n
# to get something that simplifies to
(someFunc y) x
oh interesting! I actually think x |> (someFunc y)
should be a type mismatch
(I never really thought about it before)
but if I write (foo bar)
that should just call foo
applying bar
regardless of what else is going on around it
expressions in parens should be self-contained
so I think the only way x |> (someFunc y)
would work is if I'd defined someFunc = \arg1 -> \arg2 ->
that is, I'd manually curried it
Yeah, I think that's what @Brendan Hansknecht is talking about
We're talking about different things here, though. #6 can work fine because (manullyCurriedFn 31) 11
already works.
The downside of not supporting x |> (someFunc y)
is that someone cannot just use the apply
functions that they'd use in a Record Builder to construct an applicative manually
hm, not supporting it in what sense?
what I was suggesting earlier is that x |> (twoArgFunction y)
would not work
because (twoArgFunction y)
would call the 2-arg function immediately, and that would be a type mismatch because it's only getting one argument
whereas x |> twoArgFunction y
would of course continue to work
Right. However, the desugared examples in your proposal make it look like you could either use Record Builder or pipe these curried functions. That's not currently possible. It'd be possible the way Elm does pipelines, but not in Roc.
In my current implementation, I'm not using pipes. I'm just desugaring to Expr::Apply
like this
ah, right - because the original proposal used collect
rather than succeed
I might be missing something, but I don't think Task.collect
would've changed that. This is about Task.batch
being curried.
Agus Zubiaga said:
I think we overlooked something:
Task.succeed { users <- Http.get "/users" |> Task.batch, posts <- Http.get "/posts" |> Task.batch, }
could not desugar to:
Task.succeed (\users -> \posts -> { users, posts }) |> Task.batch (Http.get "/users") |> Task.batch (Http.get "/posts")
because we would be applying
Task.batch
with 2 arguments:Task.succeed (\users -> \posts -> { users, posts })
and(Http.get "/users")
In the Task.collect
case, Task.batch
would still be applied with the same 2 arguments.
ohh gotcha
yep, I missed that!
That's why @Brendan Hansknecht suggested that x |> (manuallyCurried y)
could desugar to (manuallyCurried y) x
instead of (manuallyCurried x y)
which is a "too many args" error.
That would allow you to write:
Task.succeed (\users -> \posts -> { users, posts })
|> (Task.batch (Http.get "/users"))
|> (Task.batch (Http.get "/posts"))
oh, I think we're saying the same thing then?
so if I have a function like curriedDivide = \numerator -> \denominator -> numerator / denominator
Richard Feldman said:
so I think the only way
x |> (someFunc y)
would work is if I'd definedsomeFunc = \arg1 -> \arg2 ->
If you mean this, then yes.
yeah
I can write e.g. answer = (curriedDivide 6) 2
and end up with answer = 3
Cool, them |>
currently has a bug cause it doesn't work that way.
yeah I think it has a bug :big_smile:
Ok, great. I think that makes sense intuitively. When I write x |> (someFunc y)
, |>
should not be concerned with was is inside the parens.
Fixing that would allow libraries to expose a single Task.batch
-like function that could be used with Record Builders or that can be piped to manually construct something else.
yeah I think it's fine to have that function be curried manually, because it unavoidably has a highly unusual type signature no matter what
it's not like the first one of these is normal-looking :big_smile:
batch : Task a err, Task (a -> b) err -> Task b err
batch : Task a err -> Task (a -> b) err -> Task b err
:thinking: given how uncommon this is, I wonder if we should format it as:
batch : Task a err -> (Task (a -> b) err -> Task b err)
just to make it extra clear that it's a 1-arg function which returns a function
in a curried language you'd know that because you encounter it very early on in your learning, but in Roc this would be so rare it might be confusing to beginners what two ->
s in the same type signature means
That might already be the case. IIRC the current parser requires the parens actually.
Yup:
── NOT END OF FILE ───────────────────────────────────────── src/Pg/Result.roc ─
I expected to reach the end of the file, but got stuck here:
135│ apply : Decode a err -> Decode (a -> b) err -> Decode b err
^
The error could probably be improved though
nice - what does the formatter do?
(hopefully it doesn't remove the parens!)
Crashes without the parens, and doesn't remove them when they are present
cool!
Made an issue: https://github.com/roc-lang/roc/issues/5379
What I remember from Haskell is that the function arrow is right-associative because of automatic currying. So it makes sense to me that the parentheses are necessary in Roc since it doesn’t automatically curry
I'm still thinking about ways to improve errors and such, but it's now in a pretty good state and I opened a draft PR: https://github.com/roc-lang/roc/pull/5389
I'm pretty new to Rust and the compiler source, so please let me know if I did anything horrible :smiley:
I'll explore adding a new CalledVia
tomorrow. I think it could help me give better hints for type-errors.
Without adding any special code for Record Builders, mismatched record types produce pretty good errors already!
CleanShot-2023-05-09-at-22.52.372x.png
These are actually coming from the generated closure, but I don't think they need any special handling
The following is one we could improve I think:
CleanShot-2023-05-09-at-23.03.102x.png
In that case, I forgot the |> apply
at the end
Also, this:
CleanShot-2023-05-09-at-22.58.232x.png
It'd be great to suggest replacing the <-
with a :
in that case.
I'm going to start with that last one
CleanShot-2023-05-09-at-23.18.332x.png
Wording might have to improve, but I think that's helpful
This is very cool :sunglasses:
In the following code the title
field is missing a call to apply
:
succeed {
id <- Pg.Result.i32 "film_id" |> apply,
title <- Pg.Result.i32 "title",
}
This is how the error looks without any special logic for record builders:
CleanShot-2023-05-09-at-23.03.102x.png
and this is what I have so far:
CleanShot-2023-05-10-at-00.39.062x.png
which I think it's an improvement, but I'm not convinced
It's tricky because we don't know which function they might need to call
Also the wording seems awkward
I'm gonna go sleep on it!
Yeah, definitely a weird error message both ways, but I don't have an immediate idea how to make it better.
This week has been super busy for me, but I finally got some time today to rewrite the parser and improve the errors. I think I'm ready to submit this for review!
I've been looking at https://github.com/roc-lang/roc/pull/6995 which adds a tutorial section on the new Record Builders, thank you @Sam Mohr
I'm thinking as this is in Advanced Concept section and it should provide only a minimal syntax sugar. Enough that someone who already familiar with the advanced concepts but not necessarily roc syntax would be ok with. We can generally wave our hands about what it is but no try to explain it; instead we can point the reader to a more detailed guide which walks it through from start to finish and builds up to the full feature.
I'm finding I have to really think about things like what it is, how it works, why I would use it, etc. I think a well designed API using record builders will be intuitive for people to use, at least the Sqlite one is very easy to work with as long as I can see a worked example. So I think a very surface level knowledge, essentially "what is this syntax called, this is where I can find more information, and don't sweat the details it's an advanced concept", is ok for a tutorial.
I'm interested to know what others think. It's not easy to write good content and I appreciate the effort that Sam has put into the PR.
To clarify -- I'm wanting to clarify the intent for the tutorial section.
So I guess I'm thinking; I should have a go at writing a guide for Record Builders to explore this further.
Last updated: Jul 05 2025 at 12:14 UTC