Stream: beginners

Topic: Cannot add fields to a record with default fields


view this post on Zulip Aurélien Geron (Sep 16 2026 at 02:52):

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?

view this post on Zulip Aurélien Geron (Sep 16 2026 at 02:56):

Here are some options I tried, none of which worked:

view this post on Zulip Aurélien Geron (Sep 16 2026 at 03:04):

I 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?

view this post on Zulip Aurélien Geron (Sep 16 2026 at 03:31):

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)

view this post on Zulip Richard Feldman (Sep 16 2026 at 21:39):

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"?

view this post on Zulip Aurélien Geron (Sep 16 2026 at 23:10):

Exactly

view this post on Zulip Aurélien Geron (Sep 16 2026 at 23:11):

I can do this with a match, but there's a couple combinatorial explosion if you add more optional fields.

view this post on Zulip Richard Feldman (Sep 17 2026 at 00:22):

hm, interesting...I never really considered supporting that case :thinking:

view this post on Zulip Richard Feldman (Sep 17 2026 at 00:23):

can you say more about what this is being used for?

view this post on Zulip Aurélien Geron (Sep 17 2026 at 02:18):

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 }

view this post on Zulip Aurélien Geron (Sep 17 2026 at 02:32):

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 })
        }
    }
}

view this post on Zulip Aurélien Geron (Sep 17 2026 at 02:44):

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.

view this post on Zulip Luke Boswell (Sep 17 2026 at 02:45):

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?

view this post on Zulip Aurélien Geron (Sep 17 2026 at 02:47):

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 :?

view this post on Zulip Aurélien Geron (Sep 17 2026 at 03:18):

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.

view this post on Zulip Richard Feldman (Sep 17 2026 at 03:27):

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:

view this post on Zulip Richard Feldman (Sep 17 2026 at 03:27):

so if it's not blocking, I'd rather revisit later

view this post on Zulip Jared Ramirez (Sep 17 2026 at 03:50):

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)

view this post on Zulip Richard Feldman (Sep 17 2026 at 03:57):

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:

view this post on Zulip Aurélien Geron (Sep 17 2026 at 10:28):

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.

view this post on Zulip Richard Feldman (Sep 17 2026 at 14:52):

also TypeScript (at the type level, not the value level)


Last updated: Sep 24 2026 at 15:59 UTC