Sam Mohr said:
I think with the map2-based record builders landed, there's no more need for map3 and mapN beyond map2 for Result or Task
I don't think those are related to record builder at all. I think they are just convenience functions. If you have n results from separate paths you can pass them all to a single function (assuming they all end up being Ok). The most likely alternative is using Result.try
n times and then passing the values into the function.
That said, I still don't think they are needed. I think pushing for Result.try
and eventually ?
will be a much nicer API.
@Brendan Hansknecht I agree that we don't need record builders, they're just an alternative way to manage multiple Results to the ? desugaring. For example,
{ a, b, c } =
{ Result.map2 <-
a: resultA,
b: resultB,
c: resultC,
}?
should be the same as
a = resultA?
b = resultB?
c = resultC?
But the first can be used to create a Result without the return type of the function being a Result.
Sure, but that still is different than the mapN functions listed in the issue above.
Sure, you're right
I wasn't trying to compare ?
to record builder. More trying to mention what the goal of the mapN functions was for.
Also, never thought of using record builder like that
Yeah, I was thinking of what you'd want to do that would use a mapN function, not something analagous to a mapN's literal usage, which isn't the same thing
And yes, that's the glory of @Agus Zubiaga 's design, it's really awesome that so much can be done with so little
I guess it is the same as:
{a, b, c} =
{
a: resultA?,
b: resultB?,
c: resultC?,
}?
I'd say you shouldn't need that last ? around the record literal, but yes
It is actually important to require the final ?
in my opinion. Enables aggregating the error and then acting on it instead of propagating it.
mergedRes =
{
a: resultA?,
b: resultB?,
c: resultC?,
}
when mergedRes is
Ok {a, b, c} -> ...
Err _ -> ...
That said, definitely depends on exactly how we decide to desugar.
Which of these two is it equivalent to:
x = resultA?
y = resultB?
z = resultC?
{a, b, c} =
{
a: x,
b: y,
c: z,
} # a `?` here would be a type error with this desugaring
{a, b, c} =
x = resultA?
y = resultB?
z = resultC?
{
a: x,
b: y,
c: z,
}?
I think the second desugaring is more useful.
@Brendan Hansknecht can you move this to another thread? This is useful discussion, but I don't want to distract from this thread's purpose. I'd do it myself, but I don't have the right perms
So the reason why I think the second proposed behavior is wrong is because it doesn't map onto my understanding of how ? works in Rust, which is how I assume Roc intends to work. In Rust, ? returns errors to the top of the function no matter how many blocks nested you're in. The only thing that breaks this is nested closures, which are their own functions, anyway
For sure, it definitely differs from rust. That said, I think it matches !
today. Which is probably why I expect some differences.
Meaning that
{a, b, c} =
x = resultA?
y = resultB?
z = resultC?
{
a: x,
b: y,
c: z,
}?
would already have x
, y
, and z
extracted from their results, not only within the scope of the record literal's block
Okay, if this is how ! works today, I'm good with that
! and ? should work identically IMO anyway
I'll have to double check the impl for !, then
Note, I'm not actually sure !
works with records at all today. But it definitely works that way if you do something like:
x =
y = task1!
z = task2!
{y, z}
In this case, x
will be a Task
. The outer function is not required to be a task.
Hmm
Of course with task, in essentially all cases, you will end up merging all task together and aggregating into a single error type.
I'd posit that this is not desired, but I think having it return to the top-level function block isn't ideal because then we break how platforms that expect a main : Task I32 _
work, since they aren't functions
Yeah, okay, I can see the logic with your above example
Note, the reason it works this way is cause it is super simple sugar:
x =
Task.await task1 \y ->
Task.await task2 \z ->
{y, z}
But ideally, you'd not have to write
{a, b, c} =
{
a: resultA?,
b: resultB?,
c: resultC?,
}?
and could instead write
{ a, b, c } =
{ Result.map2? <-
a: resultA,
b: resultB,
c: resultC,
}
and avoid all the duplication of ?
I think it matches the roc viewpoint of:
Okay, so that last point...
Doesn't ! work like an early return? Is "no early returns" a core principle?
I'm trying to think of the best wording here. Fundamentally, the real principle is there are no ifs without elses.
So maybe that is still just
so you have an if
expression that always includes an else. The expression as a whole returns a value, so there is no early return technically.
I think allowing early returns is preferable for writing clean code. AFAIK we have a way to use ! in when
and if
statements so that you don't have to pull out intermediate vars
I'd say that early returns are exactly an if-else
statement with sugar
Fair enough. As long as you have an else
with a block that includes everything else, that is an early return kinda.
If I write
fn parse_data(s: &str) -> Result<Data, Error> {
if s.is_empty() {
return Err(Error);
}
s.parse()
}
There's an implicit else
But I think the core distinction is that you can't skip any blocks. You have to return through each layer of the expression tree
So !
inside of an if
is not early returning. It is creating a value passed as the result of the if
. That value could then be returned, but that is not guaranteed
So everything is guaranteed to be isolated to some extent.
If I see
x =
# tons of code
finalFn a q
z =
# something with x I guess
I know that z
is guaranteed to be run. x
is capturing the entire expression with all of that tons of code. There are no early returns. If the tons of code includes !
, x
is a Task
. If the tons of code includes ?
, x
is a Result
. x
may be some other type, but fundamentally, x
is the result of some expression and then we are continuing forward.
That's what I mean by no early returns.
I can understand wanting isolation as a principle, but I think this is valid under the "everything is an expression" rule:
main =
firstHalf =
input = Stdin.line! {}
Str.withPrefix input "first half: "
secondHalf =
input = Stdin.line! {}
Str.withPrefix input "second half: "
Stdout.line! "$(firstHalf) $(secondHalf)"
The problem if this works is that we lose isolation, but we get the convenience of not needing to await firstHalf
and secondHalf
while running things.
So yes, in our documentation of ! and ?, I think we need to emphasize that we break the mental model of Rust for the sake of maintaining isolation
I would not expect that code to work in Roc. Would need to be:
main =
firstHalf =
input = Stdin.line! {}
Str.withPrefix input "first half: "
secondHalf =
input = Stdin.line! {}
Str.withPrefix input "second half: "
Stdout.line! "$(firstHalf!) $(secondHalf!)"
Unless the thing we both seem to agree on (the current behavior is good and should be kept) is actually bad and needs to be made to follow Rust
Brendan Hansknecht said:
I would not expect that code to work in Roc. Would need to be:
main = firstHalf = input = Stdin.line! {} Str.withPrefix input "first half: " secondHalf = input = Stdin.line! {} Str.withPrefix input "second half: " Stdout.line! "$(firstHalf!) $(secondHalf!)"
Yes, it wouldn't work with current rules, only if we changed Roc to work like Rust
yep
But yeah, we definitely could change !
desugaring to make that work.
It is kinda jumping up an extra scope, but that might be ok
No, unless it's really annoying to people, I prefer the isolation we get to the scope level. I think it will be really annoying to have it work the other way
I think this just requires documentation in the tutorial
This is definitely one of those things where working through the journey of how roc got to !
makes the isolation feel totally normal, but obvious that is now how new users will feel. From nested closures to backpassing to !
.
I wonder what the best way to teach this is.
Makes me also think of:
input = Stdin.line {}
|> Task.mapErr! StdinErr
When I first saw that I was super confused. !
felt like it was in a nonsensical place
Yeah, great similar example to bring up
Makes perfect sense as a simple design choice once you get it
It feels like watching Arrival haha
Sam Mohr said:
I'd say that early returns are exactly an
if-else
statement with sugar
in Roc this is true (although in other languages it's more complicated because of things like finally
) - we've never talked about return
as an idea! Do you want to start a thread in #ideas about it?
Brendan Hansknecht said:
I guess it is the same as:
{a, b, c} = { a: resultA?, b: resultB?, c: resultC?, }?
an argument for not requiring the }?
is that (as Sam noted earlier) you can already get that behavior using record builders:
{ a, b, c } =
{ Result.map2 <-
a: resultA,
b: resultB,
c: resultC,
}?
and I definitely agree that !
and ?
should have the same rules
using !
with records seems much more common, and there I think we wouldn't want }!
- e.g.
user = {
username: readUsernameFromFile!,
posts: getPostsFromUrl! url,
}
I don't think anyone would expect to have to write that as:
user = {
username: readUsernameFromFile!,
posts: getPostsFromUrl! url,
}!
Fair. This is mostly a question of what counts as a nested expression.
As mentioned by Sam above, we could also make this work if we wanted:
main =
firstHalf =
input = Stdin.line! {}
Str.withPrefix input "first half: "
secondHalf =
input = Stdin.line! {}
Str.withPrefix input "second half: "
Stdout.line! "$(firstHalf) $(secondHalf)"
We just have to make the rules consistent enough that we don't confuse users too much
Also, going a step farther, what about this:
tasks = {
userTask: {
username: readUsernameFromFile!,
posts: getPostsFromUrl! url,
},
adminTask: {
username: readUsernameFromFile!,
posts: getPostsFromUrl! url,
secretStuff: readSecretAdminOnlyStuff!,
},
}
if user.isAdmin ... then
{username, posts, secretStuff} = tasks.adminTask!
...
else
{username, posts} = tasks.userTask!
...
we could also make this work if we wanted:
Yeah, I've hit that one a couple times as well. I think if that does not work it will be confusing for users who don't deeply understand ! desugaring
so the idea there would be that we automatically insert Task.ok
at the end of the expression?
if so, I can see that being useful but also potentially confusing in conditionals, e.g. this wouldn't work:
foo =
if blah then
input = Stdin.line! {}
Str.withPrefix input "first half: "
else
"stuff"
because it would have to be Task.ok "stuff"
since the sugar wouldn't expand to the else
branch
we could potentially make the rule be that if there's a !
in one branch, we treat all the other branches as having a !
too :thinking:
and on those grounds apply the transformation to all of them
That sounds reasonable
Richard Feldman said:
so the idea there would be that we automatically insert
Task.ok
at the end of the expression?
I just realized a cool thing about this idea:
Task.ok
at the end, that will be a problem if you already happened to have a Task
at the end, because it would become a Task (Task ...) ...
- which isn't what you want!
on it and we won't add the Task.ok
"!
is optional but doesn't do anything, but it's encouraged, although roc format
can't enforce it one way or the other" - and in this world, you would actually have to use trailing !
when you want to return a Task
because otherwise you'd get a type mismatchso it would eliminate the unnecessary (and unintentional, but currently unavoidable) stylistic option there
and make the style more consistent!
Richard Feldman said:
so the idea there would be that we automatically insert
Task.ok
at the end of the expression?
I don't think that was the idea. I think the idea was that !
would desugar to the outer scope.
hm, I don't follow :thinking:
Essentially, the question was, should this code:
main =
firstHalf =
input = Stdin.line! {}
Str.withPrefix input "first half: "
secondHalf =
input = Stdin.line! {}
Str.withPrefix input "second half: "
Stdout.line! "$(firstHalf) $(secondHalf)"
do the same as this code?
main =
input0 = Stdin.line! {}
firstHalf =
Str.withPrefix input0 "first half: "
input1 = Stdin.line! {}
secondHalf =
Str.withPrefix input1 "second half: "
Stdout.line! "$(firstHalf) $(secondHalf)"
Many beginners expect those two pieces of code to be equivalent. It also is more similar to how ?
works in rust.
hm, I don't think beginners would write the first thing though :big_smile:
so that seems moot in this case, although maybe there's another case where it might come up?
I would assume similar cases exist. That said, I think where a beginner is most likely to trip up would be related to conditionals and the trailing Task.ok. so your idea above seems helpful
yeah I think that idea is worth exploring!
Last updated: Jul 06 2025 at 12:14 UTC