hey folks! today optional & default records fields landed: #10320 !
a : { req : U8, def : U8 ?? 10, opt ?: U8 }
a = { req: 1 }
b : { opt ?: Str }
b = { opt: "hello!" }
main! = |_args| {
# we did not provide `opt` on `a`, so it's missing
expect a.?opt == Err(MissingField)
# nor did we provide `def`, but it was defaulted
expect a.def == 10
# we did provide `opt` on `b`, but since it's optional we must access thru `.?`
expect b.?opt == Ok("hello!")
echo!("Hello, World!")
Ok({})
}
How does it interact with encoding/decoding?
for JSON encoding, optional fields corrospond to presence of the field, so given{ opt ?: Str }:
opt is present, it's a regular JSON object with the fieldopt is absent, it would be an emtpy objectnote that null != optional, so { opt: null } would _not_ en/decode into { opt ?: Str }
With JSON decoding, can this feature help distinguish between a missing field and one explicitly set to null?
Jared Ramirez said:
note that
null!= optional, so{ opt: null }would _not_ en/decode into{ opt ?: Str }
So what does { opt: null } decode into then?
right now we're representing "nullable string" as Try(Str, [Null])
I thought I'd provide some feedback along with some details (as I understand them) for people who are reading zulip. I pulled main just after I posted yesterday so if there's been changes today, I haven't picked them up yet.
An optional field in the declaration is syntax sugar for the closed structural union [#Missing, #Present( val)] with the # making it compiler internal but otherwise the same as writing [Missing, Present(val)] yourself. The marker for which of the two choices the field contains takes up some extra space in the struct just like if you'd write it out yourself and there's no V8-like hidden class trickery to have structs with missing fields take up less space in a list. Structs are full layout size regardless of how many optional fields are populated.
A default field does not have the extra space taken by a discriminant and the missing fields in the literal get filled in as needed when the type containing them propagates into a record literal. The only place I've turned up that doesn't work is with a bare list in the argument position:
C : { a : U64, b: U64 ?? 5 }
foo : List(C) -> U64
foo = |x] List.len(x)
bar = |_| foo([{ a: 1 }, { a: 2 }])
# This argument has the type: List({ a: U64 })
# But `foo` needs: List(C)
Any other shape you'd come up with to produce the same result does work; it's just this specific pattern that's has an issue.
The default values have a number of restrictions. They must be literals so False, [{ foo: 1}], Some(3), ("x", "y") are all okay, which is good . Arithmetic 8 + 2, pure function calls Str.concat("a", "b"), string interpolation "a${"b"}", lambdas |x| x, and named values Style.empty are not literals. The last one is disappointing to me trying to do a UI library because I'd like to do background: Color ?? Color.transparent.
The whole expression also needs to be concrete and not just the default value: A(a) := { t : [None, Some(a)] ?? None } # DEFAULT VALUE NOT CONCRETE. I ran into this because my component design has Svelte-style overridable subtrees label: [Default, Custom(View(Model))] and ?? Default doesn't work. Setting them as optional is the same and what I'll do if I wind up going with struct authoring instead of the current builder syntax. I can get a segfault in my app by passing a View as the default but it doesn't reproduce on toy examples. I can try to have claude come up with a reduction if desired.
The other gotcha is that separately written defaults do not unify even when they're byte identical:
X : { n : U64 ?? 5 }
Y : { n : U64 ?? 5 }
R : { n : U64 }
foo: R -> U64
bar: X -> U64
foo(x) #OK
foo(y) #OK
bar(r) #OK
bar(y) # TYPE MISMATCH
This restriction is deliberate to avoid two competing defaults and there's a useful error telling you to type alias. This is fine if you control the definitions but at the moment importing type aliases across type modules is segfaulting. I haven't reported it because it's pretty simple to work around by re-defining across modules and have the identical shapes unify.
For the interaction between the two features, things mostly work as expected. In particular doing .? on a defaulted field will produce OPTIONAL ACCESS OF REQURED FIELD which is the behavior I'd expect though the error message is technically not correct. I appreciate that x.?y.?z ?? Miss is working as expected.
Optional fields are forbidden across the host boundary transitively and the compiler will provide a helpful error message if you try. Default fields get the default value.
Despite my complaints the feature is generally working and I do appreciate the efforts.
oh yeah ?? at the type level should only be supported in nominal type declarations, not in type aliases or structural record annotations, oops! :sweat_smile:
unless @Jared Ramirez you had some idea of how to make it work
I think it's doable to make arbitrary expressions supported after ?? in types :thumbs_up:
evaluated at compile time, of course
yup, i think we can do any pure expressions for rhs of ??. i went with literal only to keep to cut scope for a first pass
this is suprising!
C : { a : U64, b: U64 ?? 5 }
foo : List(C) -> U64
foo = |x] List.len(x)
bar = |_| foo([{ a: 1 }, { a: 2 }])
i'll take a look at this, this _should_ work
we could restrict defaults to only work for nominal records if we want! the type machinery would be the same, we would just restrict unification to only allow missing defaults to get "filled in" in known nominal contexts, vs current behavior where it works for records in general
yeah it seems odd to me to say like "the type of this structural record is 'a field named a whose type is U64, and a field named U64 which is optional but defaulted to 5" - and then if you have another such structural record with all the same types, but a different default value, then that's a type mismatch :sweat_smile:
so my general sense is that optional makes sense for either nominal or structural, but defaulting makes sense for nominal types only
Richard Feldman said:
but a different default value, then that's a type mismatch
we could change this error to not say "type mismatch" so it's not as weird, but that doesn't solve same default issue @Karl mentioned:
X : { n : U64 ?? 5 }
Y : { n : U64 ?? 5 }
R : { n : U64 }
foo: R -> U64
bar: X -> U64
foo(x) #OK
foo(y) #OK
bar(r) #OK
bar(y) # TYPE MISMATCH
then for context, here's the current (kinda crazy haha) erorr message:
**Type Mismatch**
The two elements in this list have incompatible types.
**test:9:11:9:12:**
lst = [a, b]
^
The first element has this type:
{ x: U8 ?? 1 }
However, the second element has this type:
{ x: U8 ?? 2 }
All elements in a list must have compatible types.
__Note:__ You can wrap each element in a tag to make them compatible.
To learn about tags, see <https://www.roc-lang.org/tutorial#tags>
**Hint:** The `x` field has a `??` default in both types, but they are two DIFFERENT defaults—two separately written defaults never merge, even when their values look the same. To share one default, declare the record type once (e.g. as a type alias) and annotate both values with it.
One default is declared here:
b : { x: U8 ?? 2 }
^
And the other is declared here:
a : { x: U8 ?? 1 }
but in any case, default only allowed for nominals solves these problems and sounds reasonable to me!
i have a branch of fast follows already in the works, i can fold this stuff in :thumbs_up:
awesome, sounds good! :tada:
Last updated: Sep 03 2026 at 15:16 UTC