The following code fails:
a : { foo ?: U64, bar ?: U64 }
a = { foo: 123 }
b : { foo ?: U64, bar ?: U64, baz ?: U64, qux ?: U64 }
b = { foo: a.?foo, bar: a.?bar, baz: 456 }
expect b == { foo: 123, baz: 456 }
The error is:
── ✗ type mismatch ──────────────────────────────────────────────────────────────────────────────────────── test.roc:5:5
This expression is used in an unexpected way.
b = { foo: a.?foo, bar: a.?bar, baz: 456 }
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
It has the type:
{ bar ?: Try(U64, [MissingField]), baz ?: U64, foo ?: Try(U64, [MissingField]), qux ?: U64 }
But the annotation says it should be:
{ bar ?: U64, baz ?: U64, foo ?: U64, qux ?: U64 }
Roc application crashed with this message:
runtime error
── ✗ fail ───────────────────────────────────────────────────────────────────────────────────────────────── test.roc:7:1
expect b == { foo: 123, baz: 456 }
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Ran 1 tests in 5.6 ms.:
0 passed
1 failed
0 compiler errors
The only way I found to make this work is to handle all 4 possible combinations of presence/absence for a.foo and a.bar:
b = {
match a.?foo {
Ok(foo) => match a.?bar {
Ok(bar) => { foo, bar, baz: 456 }
Err(MissingField) => { foo, baz: 456 }
}
Err(MissingField) => match a.?bar {
Ok(bar) => { bar, baz: 456 }
Err(MissingField) => { baz: 456 }
}
}
}
Obviously this is not ideal, to say the least.
Is there a better way to get this to work?
Here are some options I tried, none of which worked:
b = { ..a, baz: 456 } → cannot add new fields to a record with the record update syntaxb = { foo: a.foo, bar: a.bar, baz: 456 } → accessing an optional field as if it's always presentb = { foo ?: a.foo, bar ?: a.bar, baz: 456 } → syntax errorI feel like b = { foo: a.?foo, bar: a.?bar, baz: 456 } should work, should I file an issue or am I missing something?
To me, {x: r.?y} implies that both x and y are optional, right?
I have a similar problem trying to set an optional field to missing:
a : { foo ?: U64, bar ?: U64 }
a = { foo: 123, bar: 456 }
b : { foo ?: U64, bar ?: U64 }
b = { ..a, foo: Err(MissingField) }
expect b == { bar: 456 }
Edit: actually this works: { ..a, foo: _ } (but it doesn't solve the problem of adding a field)
for the original one, am I correct that the goal is to say "omit this field if this other record has that field omitted, and if the other record has that field set, then make this field be set to that value"?
Exactly
I can do this with a match, but there's a couple combinatorial explosion if you add more optional fields.
hm, interesting...I never really considered supporting that case :thinking:
can you say more about what this is being used for?
Sure! In the new zipper exercise, I have defined this Tree type:
Tree := { value : U64, left ?: Tree, right ?: Tree }.{...}
It's quite convenient to build a binary tree using this type, for example:
default_tree : Zipper.Tree
default_tree = { value: 1, left: { value: 2, right: { value: 3 } }, right: { value: 4 } }
The exercise is about building a zipper for this binary tree structure. The user is free to implement it as they wish, but it must pass tests like this one:
expect {
zipper = default_tree.to_zipper()
result = zipper.left()?.right()?.up()?.set_value(5).to_tree()
result == { value: 1, left: { value: 5, right: { value: 3 } }, right: { value: 4 } }
}
In my example solution (Example.roc), I wanted to use the following Zipper type:
Zipper :: {
focus : Tree,
crumbs : List(Crumb),
}.{
Crumb : [
Left({ value : U64, right ?: Tree }),
Right({ value : U64, left ?: Tree }),
]
left : Zipper -> Try(Zipper, [ChildDidNotExist])
left = |zipper| {
left_child = zipper.focus.?left ? |MissingField| ChildDidNotExist
crumbs = zipper.crumbs.append(Left({ value: zipper.focus.value, right: zipper.focus.?right }))
Ok({ focus: left_child, crumbs })
}
...
}
The left function builds a new Zipper with the focus on the left child of the given Zipper's focused node.
However, the compiler thinks that the type of { value: zipper.focus.value, right: zipper.focus.?right } is { value: Tree, right: Try(Tree, [MissingField]) } instead of { value: Tree, right ?: Tree }.
One solution might be to always assume that a field of type Try(..., [MissingField]) is actually an optional field.
Alternatively, perhaps we could allow { value: zipper.focus.value, right ?: zipper.focus.?right } to make it explicit.
Edit: to clarify the alternative solution:
a : { foo: U64, bar ?: U64 }
# the following would be equivalent:
a = { foo: 123, bar ?: Err(MissingField) }
a = { foo: 123 }
# the following would also be equivalent:
a = { foo: 123, bar ?: Ok(456) }
a = { foo: 123, bar: 456 }
# we could write:
b : { baz: U64, bar ?: U64 }
b = { baz: 789, bar ?: a.?bar }
Oh I forgot, the zipper story continues: to work around the previous issue, I had to change the Crumb type to this:
Crumb : [
Left({ value : U64, right : Try(Tree, [MissingField]) }),
Right({ value : U64, left : Try(Tree, [MissingField]) }),
]
This allows the previous left function to compile. Crumb is internal, so not a big deal. However, the up function is still problematic: it needs to build a new Zipper focused on the parent node of the currently focused node. I wanted to implement it like this:
up : Zipper -> Try(Zipper, [FocusWasOnRoot])
up = |zipper| {
match zipper.crumbs {
[] => Err(FocusWasOnRoot)
[.. as rest, last] => {
focus : Tree
focus = match last {
Left(parent) => { value: parent.value, left: zipper.focus, right: parent.?right }
Right(parent) => { value: parent.value, left: parent.?left, right: zipper.focus }
}
Ok({ focus, crumbs: rest })
}
}
}
However, the compiler doesn't see this as a Tree: { value: parent.value, left: zipper.focus, right: parent.?right }. Indeed, it sees this as type { value : U64, left : Tree, right : Try(Tree, [MissingField]) }.
The solutions proposed above would also work here.
Instead, the current implementation is this abomination (I'm not sure it can be simplified):
up : Zipper -> Try(Zipper, [FocusWasOnRoot])
up = |zipper| {
match zipper.crumbs {
[] => Err(FocusWasOnRoot)
[.. as rest, last] => {
focus : Tree
focus = match last {
Left(parent) => {
match parent.right {
Ok(parent_right) => { value: parent.value, left: zipper.focus, right: parent_right }
Err(MissingField) => { value: parent.value, left: zipper.focus }
}
}
Right(parent) => {
match parent.left {
Ok(parent_left) => { value: parent.value, left: parent_left, right: zipper.focus }
Err(MissingField) => { value: parent.value, right: zipper.focus }
}
}
}
Ok({ focus, crumbs: rest })
}
}
}
Side-note, it's kind of odd to have the ? before : but after ., I would find :? more consistent. But that's a totally independent point.
Aurélien Geron said:
Side-note, it's kind of odd to have the
?before:but after., I would find:?more consistent. But that's a totally independent point.
Do you think we should change it now before it impacts too many people?
Yes. I've made the mistake of typing :? a few times, so I think many people will also struggle. I don't think there's any ambiguity so both could be supported for some time, and roc fmt would turn ?: into :?
Ok, I've just found a nicer workaround by defining Crumb: [Left(Tree), Right(Tree)], but I really hope this doesn't convince you to keep the status quo, because copying an optional field into a new record feels like something that should be possible.
oh I'm not saying we shouldn't support it, just that I don't want to open a design can of worms right now when trying to focus on bugs and performance for 0.1.0 :smile:
so if it's not blocking, I'd rather revisit later
i also found :? more intuitive when originally working on this, but i think Richard preferred ?: because it’s a more common operator in other languages! (the elvis operator i think it is called)
I'm fine with the parser recognizing both, giving a warning for :?, and having roc fmt silently swap it to the other one :thumbs_up:
Jared Ramirez said:
i also found
:?more intuitive when originally working on this, but i think Richard preferred?:because it’s a more common operator in other languages! (the elvis operator i think it is called)
Wow, I never realized how common ?: is. Apparently it's used in Kotlin, Groovy, PHP, ColdFusion, and there's even a C/C++ extension supported by GCC and Clang. It corresponds to Roc's ?? operator.
also TypeScript (at the type level, not the value level)
Last updated: Sep 24 2026 at 15:59 UTC