With-expressions or manually deconstructing and reconstructing records?

Hi all,

OCaml and pretty much most functional languages have a handy feature, which makes sense to call “functional record update” or a “with-expression”.

An update would just be: { record with my_fields }. Copying all the fields in a record and producing a new record where some values are different.

We could also manually desconstruct and reconstruct a record like:

let {x; y; width; height} = square in
{x + 5; y + 5; width; height}

Manually deconstructing and reconstructing records like that is more tedious boilerplate (especially for large records!), but it comes with an advantage: if you add a new field (like, say, z_index: int), you will receive a type error that the new field is missing. That can be a useful reminder if you also want z_index to have a new value in the updated record.

There is a trade-off between ease-of-writing and ease-of-maintaining here. It is easier to read and write with-expressions, but the type errors you get from the manual way could save frustrating hours of debugging as well.

I think I prefer with-expressions in languages that support them, like OCaml and Elm, but I’m second-guessing myself after performing a refactroring in Standard ML (which lacks with-expressions) yesterday and being happy that the compiler caught a bug I would have otherwise introduced.

I would be happy to hear your thoughts about how you approach the dilemma! Do you always use with-expressions, always use the manual method (probably no one here does that because of the boilerplate) or do you have some criteria that you prefer one or the other?

Maybe I’m overthinking since those in the procedural camp also don’t have type errors when introducing new fields :sweat_smile: but I would be happy to hear others’ opinions as well.

I’m not following. In OCaml also, if you add a new field, then at every location where you create a new instance of the type you will get an error (for missing z_index). For sure, at locations where you update (let’s suppose) x and y, using with, you won’t get that error, but then, why would/should you? If (again supposing) the fields x, y, and z_index should be updated together, then shouldn’t be separate fields of that record: they should be in a single field, no?

Maybe I’m misunderstanding.

2 Likes

You’re right about OCam’ (and Elmls) behaviour and I wasn’t trying to suggest otherwise. :sweat_smile: I think you also understood my post perfectly well.

Constructing a new record from scratch will give a type error when a new field is added, but a with-expression won’t.

That’s a good question. Why should one expect a with-expression to give type errors when a new field is added, when adding the field to a nested record instead can encourage upholding invariants (like all relevant fields being changed together), and also give better semantic grouping?

I thought about your question for a bit. I tend to prefer flatter records because nested records can come with their own sort of pain (not unlike manually deconstructing and reconstructing records actually…), which is probably why I didn’t consider it.

I think the solution of using nested records kind of kicks the can further down the road though, because a nested record itself may have many fields and multiple invariants to track, and continually nesting will add lots of layers of indirection, which is a pain for field access and for updating.

I’ll probably have a go at banning with-expressions in my code except for an “update-type” module (similar to, uh, OOP setters for a class I guess :sweat_smile:) and see how that works.

If all record updates go through a function call instead of using with directly, it should be easier to uphold invariants hopefully.

Adding a field to a record would then cause one to inspect the update-type module, add additional parameters to functions as necessary, and then the rest of the refactoring process should (hopefully) be quite mechanical. That sounds easier and less error-prone than allowing with expressions anywhere, and scanning every part of the codebase to check that they all do what they are meant to with the new field.

I’m sorry for how long-winded this post is. :sweat_smile: I thought it was worth documenting my thought process in case anyone else sees value in it.

Thanks for your reply and encouraging me to think about the problem some more @Chet_Murthy. I appreciate it.

I’m sorry, I meant something different. A with expression already contains an expression with the new field, yes? In an example with a type r having fields x,y,z if we add a field foo to r, in the expression { e with z = e2 } where e had type r why should we expect the expression to raise an error or even warning on the field foo, when it did not do so for the fields x,y ?

I hope this is clear. Sorry, typing on a phone

1 Like