following up on https://roc.zulipchat.com/#narrow/stream/231634-beginners/topic/syntax/near/259018434 - an idea I'd like to discuss here is this: what if Roc had tuple syntax where tuples were syntax sugar for a tag called Tuple?
so for example:
( Str, Str ) would be effectively a type alias for [ Tuple Str Str ] (and you could use them interchangeably)( a, b, c ) would be effectively the same as writing Tuple a b c (except from a type perspective I think we'd probably want to make it be a closed union instead of an open union - but that's a detail most people wouldn't notice)List.map3 names1 names2 names3 Tuple to get a List ( Str, Str, Str )the type (Str) and the expression (a) would continue to not be tuples, as there's no point in having a 1-tuple anyway, and we wouldn't add () since that would be redundant with {} anyway
love to get anyone's thoughts on that idea!
I originally had the idea that we could use something a tag like Pair or Tuple directly, or record destructuring, but it seems in practice that a lot of use cases that use tuple in other languages - most recently Random.step in Elm - it's significantly less nice not having tuple syntax, so I'd like to talk about the idea of adding it
This missing syntax is something I wondered since I first saw Roc. ( a, b, c) is very clean, convenient and I can't think of any significant downsides.
Yeah I think this would be great! It really feels like a case where a little bit of syntax sugar will make a big difference to how comfortable it is to use.
I love tuples, and they are really helpful to circumvent a lot of ceremony to get stuff working, and I vote Aye for them! :)
well, this seems like a polarizing one , lot of different viewpoints... :laughing:
anyone opposed to the idea?
lol I also think it's a great idea. It's not really magic or anything and very very convenient. I suspect a lot of people would want this
Sorry, I'm going to be a contrarian (again) and also bring up Elm (again again) :sweat_smile: .
When using Elm I find that I don't often use tuples and instead stick to records for clarity. Also tuples add an extra burden when making elm-review rules or when developing tools. That said, if the issue is verbosity, couldn't one write T value0 value1 instead? There's of course a little gain with having tuple syntax since you don't need to add parens around value0 and value1 if they happen to be expressions but I don't think that's enough to justify tuple syntax.
That being said, Roc isn't 100% like Elm so maybe I'm misunderstanding this pain point. I need to get around to setting up Roc one of these days so I can play around with it...
I've seen cases in rust codebases where tuple return types are _way_ over-used. e.g. cases with 5+ elements in the tuple and where code readability was really improved by swapping that for a real struct.
IMO tuples should be a pretty niche feature, and anything beyond pairs is a bit of a code smell.
I'm positive on tuples, but would they support partial destructuring? Record destructuring feels great, but I notice that when destructuring tags, _ is not allowed (or sometimes breaks the parser) for any of the payloads, and it would be annoying to have to use every element of a tuple. I'm not sure if that tag behavior is by working as intended, though.
{ a, b } = x
vs.
{ a } = x
Tuple a b = y
vs.
Tuple a _ = y
( a, b ) = z
( a, _ ) = z
_ should definitely work in tag destructuring! It would too in a hypothetical tuple syntax
@Joshua Warner incidentally, Elm only allows 2-tuples and 3-tuples to discourage going overboard with them
I use tuples mostly in Haskell: when you are folding over some structure, and besides accumulating data there is some auxiliary helper data that you need - it is much more convenient to wrap those two in a tuple, than to declare some trivial used-once datatype.
So the entire difference would be (a, b, c) instead of (T a b c), right? Cause T would be the minimal tag definition for a tuple?
correct!
So this is syntax sugar. ( a, b ) uses comma round brackets for tuples, and comma are used for arguments, and round brackets are used to override order of operations. Maybe the use of comma here will break other usage.
There can be an operator for creating tuple. Then again, the type of the operator will be different.
(a <+> b) the type of operator is T, T -> [ Tuple T T ]
(a <+> b <+> c) the type of the last operator is [ Tuple T T ], T -> [ Tuple [Tuple T T] T ]
That is awesome argument actually _against_ tuples :D
Also counter my argument for ease of use of tuples:
Roc does have on the fly tags, so there is no need for extra bureaucracy around it. I am always allowed to wrap stuff up in something made on the fly
x = List.walkLeft (\(Acc acc aux) item ->
Acc (mumboJumbo acc (jumboMumbo aux))
)
and never mentioning what is Acc actually consisting of.
So I am turning around, lets not be like Elm, lets be more like ... Ruby? :D
so a use case that @JanCVanB and I were looking at the other day was random number generation, and specifically a function like step in elm/random
comparing what Elm does (the first bullet) to some alternatives that are supported in Roc today:
step : Generator a -> Seed -> ( a, Seed )step : Generator a -> Seed -> [ Pair a Seed ]step : Generator a -> Seed -> [ Tuple a Seed ]step : Generator a -> Seed -> [ Tup a Seed ]step : Generator a -> Seed -> [ T2 a Seed ]step : Generator a -> Seed -> [ T a Seed ]step : Generator a -> Seed -> [ X a Seed ]step : Generator a -> Seed -> [ Output a Seed ]step : Generator a -> Seed -> [ Out a Seed ]step : Generator a -> Seed -> [ Ret a Seed ]step : Generator a -> Seed -> { value : a, seed : Seed }then there's the question of what it would look like to use it
comparing an Elm version (first bullet) to some alternatives that are supported in Roc today:
( num, seed3 ) = Random.step (Random.int 0 100) seed2Pair num seed3 = Random.step (Random.int 0 100) seed2Tuple num seed3 = Random.step (Random.int 0 100) seed2Tup num seed3 = Random.step (Random.int 0 100) seed2T2 num seed3 = Random.step (Random.int 0 100) seed2T num seed3 = Random.step (Random.int 0 100) seed2X num seed3 = Random.step (Random.int 0 100) seed2Output num seed3 = Random.step (Random.int 0 100) seed2Out num seed3 = Random.step (Random.int 0 100) seed2Ret num seed3 = Random.step (Random.int 0 100) seed2{ value: num, seed: seed3 } = Random.step (Random.int 0 100) seed2how do people feel about each of those options?
what others are worth considering? (I was trying Out and Ret as riffs on @Zeljko Nesic's Acc in the example above - choosing a tag name that's more tied to its role in this specific function, as opposed to something generic like "tuple" - maybe there are other names worth trying in this case?)
I can also see an argument that the version which returns a record is best, even though it's the most verbose, since it means you don't have to remember the argument order...but of course if you forget the argument order you'll definitely get a type mismatch unless somehow you were generating Seeds :stuck_out_tongue:
T2, Tuple, and Pair all sound like good names to me.
That said, presumably we'll want helper functions like Tuple.first: Tuple a * -> a and Tuple.second: Tuple * a -> a which means we'll need to be consistent about what name we use for tuples? If the Random module is using Output because it's returning Output a Seed from a function then users won't able able to use those tuple helper functions with Output a Seed, right?
Assuming I haven't misunderstood here, maybe this is evidence for just using records so there isn't a need to coordinate what tuples should be called?
The record and the python tuple ( a, Seed) stand out, which I like, because otherwise there is a lot of space-separated stuff you need to inspect a little more before you know what's what. It's hard to make a decision without writing a lot of code in multiple tuple styles.
For these Random functions I'd go for the record
presumably we'll want helper functions
I actually don't want to take that as a given. I know Elm and Haskell have them, but Rust doesn't, and it seems to be fine...so I'd like to see if we can get away with not needing them! :big_smile:
To be fair rust has field access for tuple so helpers tend to be less useful :
let tuple = (1, 2);
assert_eq!(1, tuple.0);
Does Roc have field getter functions like elm? For a record { id : Int } having the function .id : { id : Int } -> Int. If so maybe the rust syntax could be a good candidates tuple : .0, .1, … (but then we would need these to be generic over the size of the tuple, so maybe it's a bad idea)
we do have the field getter functions, yeah (which reminds me, I need to add them to the tutorial)
I hadn't thought about .0, .1 etc.
if tuples are syntax sugar for tags, then .0, .1 etc would have to work with tag unions - which is actually possible
like (Point3d 5 6 7).1 could evaluate to 6
I was gonna say it would have to only work on tag unions with a single tag, but theoretically it could work on unions with multiple tags as long as they all had payloads with the exact same type at that position :stuck_out_tongue:
a counterpoint to this direction is that records already support all this already, in a simpler way and likely with nicer error messages, so maybe it's actually an argument for choosing records over tuples
If we choose the convention to be "use a record or a tag", then that is a conscious choice to increase the variety of one-off field/tag names. This may be a good thing, to force package developers to name things, but we should expect to see every option mixed together in one app: Tuples next to T2s next to value first second fst snd f s Acc Ret and Stuff
Are there other use cases for tuples other than bunching together values for returning (or passing) them together?
the only other one I can think of is in conditionals
And I find destructuring records inconvenient, when I want to rename a field. With tuples, you just place a variable at the position, but you'd need extra syntax for renaming a field, which isn't obvious so I tend to forget how it's done.
Though that is a minor point, nothing too important.
e.g. in Elm I'd often write the equivalent of:
when ( result1, result2 ) is
( Ok a, Ok b ) -> ...
( _, _ ) -> ...
True, so it's about grouping multiple values to fit into a slot that technically only accepts one.
right
could do it with a record:
when { r1: result1, r2: result2 } is
{ r1: Ok a, r2: Ok b } -> ...
_ -> ...
...but then it's a bit annoying that you have to pick names for the fields that aren't really adding value
And now I see tuples as anynomous records. Just like lambdas are functions where you don't care about the name.
could also do it with a tag of course:
when Pair result1 result2 is
Pair (Ok a) (Ok b) -> ...
Pair _ _ -> ...
yeah pretty much!
Just omitting the field names in records might lead to issues when you mix named and anonymous fields. So that's probably why we tend to have a separate syntax for fully anonymous records aka tuples.
oh yeah, { x, y } already means something :big_smile:
but yeah, functionally tuples and tag payloads do the same thing: they give you positional field access instead of named field access
it's really just a matter of syntax (or at least it would be with the proposed design)
I think I agree with the previous statement that the parens and commas make it easier to read than the space-separated tuples, so it would be worth it.
And having a canonical tag name for tuples might also be beneficial, right?
I like the idea of forcing users to make constructs ala StrAndInt for something short that they might need because that is the only way that you pack data up in Roc.
On the other hand, i might imagine disgust it might provoke among newcomers to the language.
Tuple could also be sugar for records with numbers instead of names for fields(i.e. (1, True, "string") : (Int, Bool, String) would be sugar for { 0: 1, 1: True, 2: "string" } : { 0 : Int, 1 : Bool, 2 : String }). The drawback is that number fields would only be valid for tuple which makes the design a bit inconsistent.
interesting! I hadn't thought of that design. :thinking:
What do others think of that idea?
(also it kind of bridge the gap conceptually for the unit type, i.e. () = {})
would there be any benefit to introducing () to the language? I've never been able to think of any significant ones :big_smile:
Can't see any, I only meant that tuples and records are basically the same thing, only the naming of fields differs.
one downside of the "tuples are sugar for records with numbers for fields" idea, compared to the "tuples are sugar for a Tuple tag" idea is that you can no longer do List.map3 list1 list2 list3 Tuple thing
to support that, there'd have to be special syntax for that like (,,) in Elm
Random thought- if implemented to it's logical conclusion, that could have some fun destructuring:
a = (1, 2, 3)
(b, c, d) = a
{ 2: last } = a
f = \{ 9: target } -> target
Eh, tuple-style destructuring is probably superior.
I like the idea of representing them as special records more than tags. Two reasons for this:
I think it could be confusing for a beginner (and even someone who leaves the language for a bit, then comes back) to remember that Tuple a b == (a, b). In particular, the direction of having to remember Tuple a b can be swapped for (a, b) at any time seems a bit magical. IMO having the unidirectional desugaring of (a, b) to {0: a, 1: b} is better because it has all the same semantics present in other parts of the language, but you can't ever create a {0: a, 1: b} directly, so tuples exist in their own world.
I worry about how the use of the specialized Tuple tag may play with other constructs. For example, if I have
TwoOrThree a : [Tuple a a, Triple a a a]
I would now be permitted to do something like (suppose + is defined for a, via abilities or something else)
add : TwoOrThree a -> a
add = \t -> when t is
(a, a) -> a + a
Triple a a a -> a + a + a
Which is a little bit hard to read IMO, I would expect the shapes of everything I'm destructuring to look the same.
One way to avoid this issue in particular is to make Tuple a special tag in the sense that it can't be used as part of a larger tag union data structure. In that model when I do something like
add = \t -> when t is
Tuple a a -> a + a
Triple a a a -> a + a + a
The error message would either have to say that "Triple" can't be returned because only a "Tuple" can come out of this function, or that you should rename "Tuple" to something else. IMO this is something that may catch folks relatively often
ha, that's a good point!
well, both are good points :big_smile:
the [ Tuple a a, Triple a a a ] makes me think of an interesting consideration: if tuples are syntax sugar for records where the field names are numbers, then I can do this:
recordIdentity : {}a -> {}a
recordIdentity = \rec -> rec
recordIdentity ( 1, 2, 3 )
...but I actually can't mix tuples and open records other than the empty record (e.g. pass a tuple to an argument expecting { a : Str }*) because for that to work, I have to provide at least one named field, and tuples can't do that
however, we'd probably have to explicitly rule this case out:
tuple = ( 1, 2, 3 )
record = { a: "string", b: "string" }
mixed = { record & tuple }
otherwise mixed would have both named fields and numbered fields :stuck_out_tongue_wink:
but of course if we rule that out, then tuples are no longer syntax sugar for plain records anymore :laughing:
(although they'd still be similarly easy to teach: "Tuples work just like records except they use numbers for fields instead of names. Also you can't use & with them.")
To me that reads much like some piece of Javascript spec :D
IMO "tuples are records with number for fields" looks like a dirty idea. It feels like a ocean of features is beneath your feet.
Maybe it's good to try leaving it out at first and adding it in once people complain about specific issues. :grinning_face_with_smiling_eyes:
That's actually been the strategy for a while already! I think earlier in the thread, Richard mentioned that some use cases have been cropping up so it's time to consider whether it's time to just go for it.
yeah exactly :big_smile:
I'm ok with saying we should continue with the "wait and see" approach
also it seems like we should try defaulting to records whenever we'd otherwise reach for tuples in an API (as opposed to locally in a when or something) and see how it feels
Someone mentioned that tuples are like anonymous records. This is kind of true since tuples are anonymous, but when you discuss anonymous records I think most people would be thinking about records with row types, which are different.
Having a unit literal could still be useful when working with polymorphic values when a value isn't being used. It's also useful for thunking, infinite lists, chunking, etc. Maybe these cases don't come up often enough since defining a Unit tag is also sufficient.
I would also not have the tuple be syntactic sugar for anything and just be it's own thing. Tuple constructors of different arity should all be named differently otherwise so they don't interfere with each other.
Brendan reminded me of an earlier "tuples as syntax sugar" design we talked about:
(a, b, c) is syntax sugar for Tuple a b c
this has the advantage that you can do things like List.map2 list1 list2 Tuple to get back a list of tuples, where each tuple has one element from list1 and one element from list2
unlike the "tuples are syntax sugar for records" design, this wouldn't enable .1 and .2
.1 and .2 either require that tuples desugar to records, or else that they aren't sugar and have their own seeparate type system rules
because it has to be possible to infer the type of \x -> x.2 and that function also has to accept tuples with more than 3 elements (so it can't be sugar for \Tuple _ _ a -> a because that would only work for tuples of exactly 3 elements)
one reason I like the .1 and .2 functions is that otherwise there's inevitably demand for convenience functions like Tuple2.first, Tuple2.second, Tuple3.first, Tuple3.second, Tuple3.third etc.
one possible design we haven't talked about is to make tuples "extensible" like records, but not have update syntax
in other words, the type of \x -> x.2 is (*, *, a)* -> a
just like how \x -> x.foo would be inferred as { foo : a }* -> a
it might literally only come up in exactly that situation though
because you'd probably want pattern matches on tuples to be closed, e.g. (x, y) = foo should give a type mismatch if foo is a 3-tuple
even though it could be designed to Just Work, I think for tuples it's less error-prone to give an error for a pattern match like that
As long as users don't make overly large tuples, this syntax should be pretty terse:
(_, _, _, _, a, _, _) = foo
That is with a 7 long tuple (which probably should be a record) and is still pretty similar in length to a = Tuple7.Fifth foo
true, although the reason I usually see demand for those helper functions is to use them in pipelines and higher-order functions
e.g. you have a list of tuples and you want to extract the first element, and you do List.map tuples Tuple2.first
or if there's syntax for it, List.map tuples .0
similarly, in a pipeline:
tuples
|> List.first
|> Result.map .0
That makes sense. Not something I would ever think up, but looks really nice. Need to keep getting more used to functional programming patterns.
I don't know how I missed this, but there's a pretty nice design opportunity here if tuples are sugar for single-tag unions:
(1, 2) == Tuple 1 2
List.map2 [1, "a"] [2, "b"] Tuple
== [(1, "a"), (2, "b")]
(42, "x").1 == "x"
and then the type of the .1 function is just [Tuple * a] -> a
oh, hm that has the problem of .1 not working on longer tuples :thinking:
ok I guess they do need to be their own thing :stuck_out_tongue:
the reason I was thinking about this is that it seemed like it could also be nice to have .1 etc work on single-tag unions
but maybe there's no need to make those anymore if tuples exist
Would it make sense to introduce a second special payload type ** that enables [Tuple * a **] matching on any Tuple tag with two or more payloads? That could save this latest proposal, but I imagine it would also solve/create various other problems.
I'd say that adds more complexity/confusion than the feature would take away.
I would like to add, that for 2 years I'm writing BLOBAs in F# which is now my favorite languge (just after ROC of course :) and during that time I had to use function like fst or snd on 3-elements-tuple only ONCE. F# has not such functions for 3- and more tuples, so I've implemented it ad-hoc (one liner) just to make a pipeline process easier to read.
My point is, I would sacrifice all those .0...n superb accessors for any-dimensional tuples and KISS. Tuples are a must have in my opinion, but pattern matching + fst/snd for binary tuples are just fine!
P.S. BLOBAs are boring, line of business applications ;)
I think the main reason for .1 to .n is that tuples are theoretically how you would implement an array. Which is important for performance in some cases. Though I guess a record works too, just more tedious.
One more thing I would like to add is how cool are tuples implemented in F#, so that most of the time you can omit the brackets. It makes them look so "lightweight", comparing with eg. Elm which is btw. also my favorite language ;)
I won't provide any examples as I'm on my phone now, but please, take my word for the bracket-less tuples :)
hm, I'm not seeing it in https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/tuples :thinking:
https://fsharpforfunandprofit.com/posts/tuples/#making-and-matching-tuples has an example
neat!
I briefly thought about trying something like this once, but I assumed it would be confusing - apparently it's already been tried and is not confusing in practice?
OCaml has the same thing, but personally I do find it confusing, because depending on the context you may or may not need tuples
It would be the same in Roc, for example when defining a list literal of tuples vs appending a tuple to a list
yeah I guess in the list literal case you'd have to use the parens
interesting observation: if we have "extensible tuples" then we can actually have a Tuple module that's more useful than normal, e.g we can do things like
Tuple.mapFirst : (a)t, (a -> b) -> (b)t
Tuple.mapSecond : (a, b)t, (b -> c) -> (a, c)t
Tuple.mapThird : (a, b, c)t, (c -> d) -> (a, b, d)t
and you could call those on any tuples that were at least as long as requested
e.g. you could call mapFirst on a 2-tuple, 3-tuple, 4-tuple, etc.
Was just thinking the same things :sweat_smile: https://github.com/roc-lang/roc/pull/4358#discussion_r1003607952
Last updated: Jun 16 2026 at 16:19 UTC