In updating the Roc code in my basic-cli branch for the upcoming Task as a built-in change, I find myself defining the FFI Tasks in the hosted module as doThing : Task ok [] such that the type is static and decoding works well, and the API Tasks as doThing : Task ok * such that users can Task.await them without Roc complaining that [] and []err aren't compatible.
I wrote two functions to achieve this:
Result.infallible : Result ok [] -> Result ok *
Result.infallible = \result ->
when result is
Ok data -> Ok data
Task.infallible : Task ok [] -> Task ok *
Task.infallible = \task ->
Task.attempt task \result ->
Result.infallible result
|> Task.fromResult
They have gotten the basic-cli Tasks working without requiring a thunk to force the type system to allow multiple specializations, which is nice. It seems like they both would have value in the standard library, especially the second, since it allows users to convert Tasks that can't have the ! bang used on them to Tasks that can be easily awaited.
What do you all think? Is this too niche to meet the threshold of what goes in std? Would only one of them belong?
Also, as mentioned, even though @Anton just made the breaking change to Arg.list to take a {} to make the return type composable (at least according to my understanding of the change), it seems this approach prevents the need for that. I expect this is preferable, but it'd be good to know if anyone has a reason they disagree.
Also, the benefit of being able to use ! for Tasks should directly transfer to ? and Result once the desugaring changes land for the new ? operator (if these new infallible functions are added).
Feels like something I would hope our task/effect generation would automatically handle when calling a platform
Yeah, ideally, but there seems to be conflicts with how the type system works if we want a "simple" solution to this problem. I have a few ideas, let me get to a key card so I don't have to put these on my phone
I assume "key card" should be "keyboard" I was really confused for a bit :p
Yes, keyboard haha. I guess I got distracted here
So one class of approaches would be to change how tag union unification works with closed records.
First is to allow closed unions to unify so long as the first union considered is a subset of the others. That would mean that we could Task.await on Task Str [] in a function that returns Task Str [Err1, Err2] since [] is a subset of [Err1, Err2]. It would be convenient for a lot of functions where unification is the goal, but also could also mean that users who want to guarantee that a certain union is returned is the only thing allowed. I don't know how crazy this is, but it would make people's lives easier and also make the Task issue a moot point, since Task Str [] is still a static type, but would be easily awaitable with ! without needing weird mapping.
An alternative version of this is to special case allow [] to generalize, but considering how this would be used with Result, which is just an alias for other tag unions, this seems like it wouldn't be any better than the first option, and would be weirder to grok. A no go.
Another class of solutions involves changes to the FFI translation/codegen on the Roc side.
The first is that we always convert open unions/records to closed versions of the same types, and tell the decoding process to interpret the FFI data as the closed versions, and then generate code to convert the closed variants to their open variants. This would have another function call as overhead for either every FFI call, or just those with type variables in their return data. This would preserve the current type system, but prevent the system from encoding/decoding between Roc and the platform incorrectly. And then all hosted module declarations would just be Task Str *, no extra translation needed.
A more restrictive version of that would be to special case the * wildcard type to equate to a zero byte type. Again, I'm not a fan of the special casing because it will only be useful in a few cases and be more confusing, but could prevent us from confusing platform authors by converting down more complex types, e.g. Task Str [Rgb [Red, Blue]*, Gray]a to Task Str [Rgb [Red, Blue], Gray].
Though I know that the first suggestion is basically converting closed tag unions to a weaker form of open tag unions, my 3 AM brain can't think of examples where this would be a big issue. If someone could think of something, that would be awesome to prove why this is a bad option, it really smells like I'm missing something.
However, if this would work without breaking existing Roc code overmuch, it would mean that we avoid adding more overhead to FFI conversion, but we'd need to probably change our codegen to map these variants to their new counterparts at every unification point. i.e.
defaultColor : [Green]
defaultColor = Green
getColor = [Red, Blue], Bool -> [Maroon, Indigo, Green]
getColor = \thing , coinFlip->
if coinFlip then
when thing is
Red -> Maroon
Blue -> Indigo
else
defaultColor
at the when statement, we'd not have to convert [Maroon, Indigo]* since it's an inferred open record, but the defaultColor invocation would have to convert at runtime since it's [Green]
Which isn't great. Ideally this system would work do unification specific to all call sites (to avoid runtime conversion) except during FFI, maybe.
With infallible I'm confused that it works because of the earlier discussion we had about Arg.list.
Yeah, I was definitely thinking of having roc consider all ffi tags as closed. Then it would generate a runtime map if required.
This option makes sense to me cause the platform has to speak in statically known sizes so either everything needs to be closed or boxed. Not to mention, the platform needs to know the exact list of tags or it might generate the wrong discriminat.
So I think this is a clear place where automatic runtime mapping from closed to open (with any extra data movement for unification) makes sense
I don't think we should do this in general ...at least not without way more thought/testing. Though it is a weird edge case of tags today, this conversion might be costly. So we don't want to enable it elsewhere without more testing of the performance implications
just a general process note - I think the best way to proceed here is to ship Task as builtin without making any other changes to the builtins API (even though that maybe requires platforms to do some extra conversions like this for now) and then after that lands, we can figure out how we want to handle the resulting sharp edges like this :big_smile:
Yeah. The sharp edge exists with Effect anyway. So no different to today. Just a bug we know about now.
Anton said:
With
infallibleI'm confused that it works because of the earlier discussion we had about Arg.list.
This is what's happening in the Task.infallible call. The destructuring of the Ok data -> Ok data is where Roc maps from closed to open, and resizes the tag union from just [Ok T] to [Ok T, Err *].
So I'm okay with doing things as I'm doing them now, which is to call Task.infallible on these Tasks to convert from closed to open Err at runtime. If it turns out that users need to do this kind of conversion in multiple places, we can revisit and maybe add something to the standard library.
The destructuring of the
Ok data -> Ok datais where Roc maps from closed to open, and resizes the tag union from just[Ok T]to[Ok T, Err *].
Thanks, that makes sense. I looked back at the conversation and Brendan's comment cleared it up for me:
"this is the not in an output position, it is closed and can not merge with anything".
Last updated: Jun 16 2026 at 16:19 UTC