Stream: ideas

Topic: Allowing changing record field set via `..` operator usage


view this post on Zulip Sam Mohr (Aug 24 2024 at 16:34):

As part of the new .. syntax proposal, the new means to show an open record type alias is User a : { name : Str, age : U64, ..a }, where a is a separate record type with additional fields for User.

One of the proposed syntax additions is to allow the same syntax when capturing record fields, a.k.a { name, age, ..rest } =, where rest is a new record holding the other fields besides name and age. This would require us to create another struct type, and copy the other fields in the destructured record to this new struct.

Another proposal is to allow extending records using a spread operator, a.k.a userWithEmail = { ..user, email: "..." }. This would also require defining a new struct, copying all of user's fields over, and then setting the new email field, all at runtime.

Between these two operations, we'd get more flexibility for creating records, but would promote a potentially slow operation at runtime. Thoughts an opinions on the support of one or both of these features below!

view this post on Zulip Brendan Hansknecht (Aug 24 2024 at 16:38):

I'm definitely not a fan of this. While there are some cases it would be really convenient to avoid boilerplate, I think it is a big performance antipattern. Especially given the larger the struct you do this to (which is where this is the most convenient), is where the most performance is lost.

I also think a common pitfall that new users run into is trying to use records as dictionaries. I think this would make it possible to kinda do that in a super terrible way.

view this post on Zulip Sam Mohr (Aug 24 2024 at 16:45):

I agree, and I think by allowing either { a, b, ..rest } = or { a, b, .. } as rest = to set rest as the full record would make the same code possible without runtime overhead.

When I say "the same code is possible", I mean that if rest has the fields a and b included, it doesn't need to use them, but it at least has all other fields included, so you can still use rest.otherField.

view this post on Zulip Sam Mohr (Aug 24 2024 at 16:46):

That's actually why I'd lean away from { ..rest } and towards { .. } as rest, because it doesn't imply that a and b are dropped

view this post on Zulip Brendan Hansknecht (Aug 24 2024 at 16:52):

As I mentioned In the original thread. Generally if you want to do something like this there are better alternatives that balance perf and usability.

For {name, age, ..rest} = ..., generally you can just keep around the full record and still reference fields as needed {name, age } as rest = ..... we even allow for a record to be passed to a function that only knows about a subset of the fields due to open records. So other alternative with good perf and reasonable ergonomics exist for most uses of this.

For userWithEmail = { ..user, email: "..." }, This will definitely lead to users thinking records work as dictionaries and being frustrated when they are limited. Instead of doing this, a user can use nested records or have a record generate that is originally big enough to hold the email. If a field is know to be needed it should probably exist from the get go.

Tons of statically typed languages do just fine without these kinds of features. I really don't think we should add them. They are bad for perf and become worse for perf the more convenient they are to use.

On alternative I think might be valid that is more explicit. Allow this spread operator with record update syntax. If a user really wants to add a field, they can define a new full sized record (maybe with all default fields) then update every field from the sub record. I think this is much more clear about the cost of adding a field and it is much more optin on than accidental. It also would work with open records (which is a good thing).

userWithEmail = { defaultUserWithEmail & ..user, email: "..." }

view this post on Zulip Brendan Hansknecht (Aug 24 2024 at 16:53):

Note this alternative is essentially how rust enables something like this.

view this post on Zulip Sam Mohr (Aug 24 2024 at 16:58):

I think we should just change the record update syntax from { existing & foo: bar } to { foo: bar, ..existing } for consistency with other .. syntaxes, as well as being more obvious for JavaScript and Rust devs

view this post on Zulip Brendan Hansknecht (Aug 24 2024 at 17:01):

Oh, but that would actually break my alternative potentially

{ email: "...', ..user, ..defaultUserWithEmail }

view this post on Zulip Richard Feldman (Aug 24 2024 at 18:54):

Brendan Hansknecht said:

I'm definitely not a fan of this. While there are some cases it would be really convenient to avoid boilerplate, I think it is a big performance antipattern. Especially given the larger the struct you do this to (which is where this is the most convenient), is where the most performance is lost.

I also think a common pitfall that new users run into is trying to use records as dictionaries. I think this would make it possible to kinda do that in a super terrible way.

I kinda like the idea of parsing it and then giving an error that explains the perf cost and why you should use as instead

view this post on Zulip Richard Feldman (Aug 24 2024 at 18:55):

like "hey this would actually create a whole new record; if that's what you want, just make a new record from scratch so it's clear that's what's happening"

view this post on Zulip Sam Mohr (Aug 24 2024 at 18:58):

That's a great idea!

view this post on Zulip Brendan Hansknecht (Aug 24 2024 at 22:14):

Yeah, I like that!

view this post on Zulip Fritz Psiorz (Aug 25 2024 at 11:20):

Sam Mohr schrieb:

I think we should just change the record update syntax from { existing & foo: bar } to { foo: bar, ..existing } for consistency with other .. syntaxes, as well as being more obvious for JavaScript and Rust devs

I think this would actually be inconsistent with other .. syntaxes because the new .. is usually a spread, so ..existing says "put all of existing here."
So if existing = { foo: 1, bar: 2 } and you put { ..existing, foo: 3 }, it reads like you're assigning foo twice, as in { foo: 1, bar: 2, foo: 3 }, which isn't allowed.

Even if the syntax for adding/removing fields never makes it into the language, it would be confusing to have the same syntax but with a different meaning.

view this post on Zulip Romain Lepert (Aug 25 2024 at 19:13):

{ ..{ foo: 1, bar: 2 }, foo: 3 } gives you { bar: 2 , foo: 3 } because foo: 3 is the last foo assignment (reading left to right) so it has the last say.

This is how it is implemented in JS and is not really confusing IMO

view this post on Zulip Sam Mohr (Aug 25 2024 at 19:16):

Yeah, putting at the end as I did is wrong, yes

view this post on Zulip Sam Mohr (Aug 25 2024 at 19:17):

If we do it at the beginning, we're good, as described above

view this post on Zulip Brendan Hansknecht (Aug 25 2024 at 19:19):

requiring an order feels pretty strange to me cause records aren't ordered. It also feels very different from a spread which is additive in lists. So I'm not a fan

view this post on Zulip Sam Mohr (Aug 25 2024 at 19:39):

Actually, you're right, lists can be added onto at the end, this would work the same

view this post on Zulip Sam Mohr (Jan 18 2025 at 01:03):

We seem to have just leaned towards allowing { x, y, ..rest } = full_record to extract the remaining fields besides x and y from full_record into rest, in good part because it shouldn't affect runtime performance or compiler performance in any noticeable way.

Should we reconsider allowing this? Would it significantly worsen runtime or compiler performance with repeated usage?

view this post on Zulip Brendan Hansknecht (Jan 18 2025 at 01:22):

My gut feeling at this point is that:

  1. This is unlikely to be abused.
  2. It is unlikely to be a perf issue in practice with normal use.
  3. It is a nice feature to have with structural records
  4. It needs a very clear specification for how field overriding works
  5. Will lead to some people using records as a weird form of dictionary and being frustrated when it doesn't just work or hits a type system edge case.

view this post on Zulip Brendan Hansknecht (Jan 18 2025 at 01:25):

An important note: Any large record is already being past around as const ref. As such, any record update on it will create a copy of all of the data it contains. So this syntax will do the same, just with a relayout as well (which could add to the expense but is probably fine). Also for anything purely local (or inlined), llvm will optimize it to be nice anyway.

view this post on Zulip Anthony Bullard (Jan 18 2025 at 01:25):

How is this different than { foo & x, y }?

view this post on Zulip Brendan Hansknecht (Jan 18 2025 at 01:26):

currently {foo & x, y} requires foo to contain an x and a y field

view this post on Zulip Anthony Bullard (Jan 18 2025 at 01:26):

Or am I imaging that syntax?

view this post on Zulip Anthony Bullard (Jan 18 2025 at 01:26):

Ok

view this post on Zulip Brendan Hansknecht (Jan 18 2025 at 01:26):

This allows for the new record grow to contain more fields

view this post on Zulip Anthony Bullard (Jan 18 2025 at 01:27):

Interesting

view this post on Zulip Brendan Hansknecht (Jan 18 2025 at 01:27):

Oh, and the syntax is just swapping over everything to use .. and is removing the current record update syntax.

view this post on Zulip Anthony Bullard (Jan 18 2025 at 01:27):

So I get some record type an and then return a {x, y}a?

view this post on Zulip Anthony Bullard (Jan 18 2025 at 01:27):

They would at least be consistent

view this post on Zulip Anthony Bullard (Jan 18 2025 at 01:28):

I like it

view this post on Zulip Anthony Bullard (Jan 18 2025 at 01:28):

But I also program mostly JavaScript so I would

view this post on Zulip Brendan Hansknecht (Jan 18 2025 at 01:29):

Hmm...does this have a type issue:

fn : { ..rest } -> { a: Str, ..rest }
fn = |record|
    { ..record, a: "yes" }

Does this make sense as a type? It seems to suggest that rest doesn't contain a, but it may or may not contain an a.

view this post on Zulip Sam Mohr (Jan 18 2025 at 01:30):

I think that's how it'd work

view this post on Zulip Sam Mohr (Jan 18 2025 at 01:31):

There's a chance this could replace record builders...

view this post on Zulip Brendan Hansknecht (Jan 18 2025 at 01:31):

I think that would mean we need a different system of type variable unification. Generally the type variable here would just be adding fields. Now they need to not only add fields but also need to handle the case of a record update that might replace a field

view this post on Zulip Sam Mohr (Jan 18 2025 at 01:31):

It wouldn't look like you're building a record with these

view this post on Zulip Sam Mohr (Jan 18 2025 at 01:31):

Later, I'll try making another Weaver demo of what I mean

view this post on Zulip Brendan Hansknecht (Jan 18 2025 at 01:32):

Sure, but I don't think this type works: fn : { ..rest } -> { a: Str, ..rest }

view this post on Zulip Sam Mohr (Jan 18 2025 at 01:32):

Yep, you're right

view this post on Zulip Sam Mohr (Jan 18 2025 at 01:32):

That was an aside

view this post on Zulip Sam Mohr (Jan 18 2025 at 01:33):

Well, I'm not sure

view this post on Zulip Brendan Hansknecht (Jan 18 2025 at 01:33):

So either we need a new type syntax to deal with record updates or we need to make types for spreads to be smarter to deal with record updates either adding or mutating a field.

view this post on Zulip Brendan Hansknecht (Jan 18 2025 at 01:34):

To be fair, we could just make the syntax mirror the code: fn : { ..rest } -> { ..rest, a: Str }. Would require a different unification algorithm, but should be sound

view this post on Zulip Sam Mohr (Jan 18 2025 at 01:35):

Actually, couldn't that work with fn : rest -> { ..rest, a : Str }?

view this post on Zulip Sam Mohr (Jan 18 2025 at 01:35):

Which still requires a type system change

view this post on Zulip Sam Mohr (Jan 18 2025 at 01:35):

Since you need to know the relationship between rest and ..rest's usage

view this post on Zulip Brendan Hansknecht (Jan 18 2025 at 01:47):

ah yeah

view this post on Zulip Brendan Hansknecht (Jan 18 2025 at 01:47):

don't need the extra ..

view this post on Zulip Ayaz Hafiz (Jan 18 2025 at 03:00):

i would be surprised if this doesn’t already work

rest -> {a: Str}rest

on mobile sorry for formatting. if it doesn’t that’s a bug

view this post on Zulip Sam Mohr (Jan 18 2025 at 03:04):

Yeah, we're good:

module []

extend_with_a : data -> { a : Str }data

extend_with_b : data -> { b : Str }data

expect
    {}
    |> extend_with_a
    |> extend_with_b
    == { a: "abc", b: "def" }

view this post on Zulip Sam Mohr (Jan 18 2025 at 03:04):

This typechecks

view this post on Zulip Ayaz Hafiz (Jan 18 2025 at 03:09):

sick

view this post on Zulip Brendan Hansknecht (Jan 18 2025 at 04:28):

That isn't enough. For it to also fulfill record update syntax, this has to work too:

module []

extend_with_a : data -> { a : Str }data

extend_with_b : data -> { b : Str }data

expect
    { a: "test" }
    |> extend_with_a
    |> extend_with_b
    == { a: "abc", b: "def" }

view this post on Zulip Brendan Hansknecht (Jan 18 2025 at 04:29):

This suggested change would both allow addition and updating an existing field

view this post on Zulip Anthony Bullard (Jan 18 2025 at 11:54):

I would think that example would work today, but what I think is the more bizarre case that someone _may_ want to try is:

module []

extend_with_a : data -> { a : Str }data
extend_with_a = |data| {
    a: "abc",
   ..data,
}

extend_with_b : data -> { b : Str }data
extend_with_b = |data| {
    b: "def",
   ..data,
}

expect
    { a: 123 }
    |> extend_with_a
    |> extend_with_b
    == { a: "abc", b: "def" }

view this post on Zulip Sam Mohr (Jan 18 2025 at 11:57):

That should be a type error IMO

view this post on Zulip Anthony Bullard (Jan 18 2025 at 12:28):

I would hope so

view this post on Zulip Brendan Hansknecht (Jan 18 2025 at 17:23):

Yeah, I think that would always be a type error


Last updated: Jun 16 2026 at 16:19 UTC