a potentially nice use for custom records would be if they had the following design:
gen auto-generating a function named new for you, which takes a structural record of the same shape as the custom recordthe scenario this would make nice is the one we can see in roc-lang/http, where backwards compatibility is in tension with ergonomics.
in that module we essentially have a record (representing a Request) that we could easily choose to represent as a structural record (because all of the information in it is public), but if we do that, then every time in the future that we add a field to that record, it's unavoidably a breaking change.
the reason it's unavoidably a breaking change is that anywhere someone is constructing one of those structural records, they will suddenly be missing the new field(s), and will get type mismatches
this module currently addresses this by having a getter or setter function for every field on the record, which really clutters up the public API and also creates a bunch of annoying shadowing situations within the module
but it does solve the problem
the idea here is that we could sort of formalize this scenario
and say that a custom record can only be built from scratch from within the module, and outside the module you're stuck calling a function exposed by that module if you want to get ahold of one of them
but once you have it, you can do all the usual record things with it - read its fields, pattern match on them (although not exhaustively), update them, etc.
so that way, Request can add new fields as a nonbreaking change
because if you have a code base that updates to the new version, nothing breaks because you aren't trying to create one from scratch (which requires specifying every field, including the ones your code base didn't know about because they didn't exist at the time) or to destructure it exhaustively (which also would have required knowing about fields that didn't exist at the time)
however, your calls to Request.from_method : Method -> Request (which is how you actually create one of these) continue to work, because the module will have updated the implementation of that function to include the new fields
finally, in this design if you do actually want a "nominal record" where you can specify all the fields during construction, we can still support that use case easily by having gen support auto-generating a function named new (or init or whatever) which takes a structural record of the same shape, and then returns the custom record
so if you were using that, instead of it being MyRecord.{ field1, field2, ... } it would be MyRecord.new({ field1, field2, ... }) - so almost identical ergonomics
exhaustive destructuring still wouldn't work, but we already don't support that, so doesn't seem like the end of the world :sweat_smile:
I guess alternatively to the gen new idea, if we're having the concept of "custom tag unions either do or don't support their tags being constructed outside the module" we could offer the same thing for records
and you'd choose that option (or not) based on whether you want backwards compatibility vs. exhaustive destructuring
Would this be a good enough solution in the HTTP case? It would mean we could add new fields to the types without breaking, but we could never change the type of an existing field, rename it, or remove a field. It seems to me that in many cases if you're worried about backwards compatibility you might want to hide more implementation details than a custom record solution like this would allow.
that's already true though
in that if I change a field's type, that implies changing the getter and setter for it accordingly
Not necessarily, you could change the internal representation of the data to use different types but still expose the same public API with an opaque type
I don’t know that it would come up with the HTTP example, but it certainly could other places
I'm seriously not trolling, but would "hanging functions off" the custom type be an option here? I.e, constructors are just module functions, but all "methods" are declared using some syntax that "attaches" it to the definition of the type?
Here's a only-for-bikeshedding example (and feel free to move this out of this thread if it's just noise or unhelpful):
MyRecord := {
field1 : Str,
field2: U64,
} with
totally_not_a_method = |self| { self & field2: self.field2 + 1 }
or even
MyRecord := {
field1 : Str,
field2: U64,
} with
self.totally_not_a_method = || { self & field2: self.field2 + 1 }
These aren't methods, I know! They are "custom-type associated functions".
And then maybe you don't need the module(a).blah : ... syntax for where statements
Just where a.blah : ...
Another idea (going Lua here)
MyRecord := {
field1 : Str,
field2: U64,
}
MyRecord.totally_not_a_method = |self| { self & field2: self.field2 + 1 }
This idea sounds good to me, except for gen being a compiler-generated function. It would be easy for custom record authors to just rely on that and get easy breakage all over again.
We could take a page from the Gleam book and have a code action to generate the method def:
MyRecord := {
foo: Str,
bar: Bar, # another custom type
}
# generated with a "Generate `new` method" action
new = |{ foo, bar }|
MyRecord.{
foo,
bar,
}
Basically, it's pretty important that we have custom records, and even if we don't do something like the above, that's fine
I mean LISP has done pretty well without opaque types.
/me ducks
Does lisp have a good FFI story?
Well opaques across a FFI is different than a language facility for defining one
And Clojure does :stuck_out_tongue:
I'll check it out
Isaac Van Doren said:
Not necessarily, you could change the internal representation of the data to use different types but still expose the same public API with an opaque type
I've heard this is an important use case since 1990s and the number of times I've seen it come up in practice across my entire career has been 0 and counting :sweat_smile:
to clarify, that's for things that have fields on them
for collections, sure :thumbs_up:
but I'd just make them opaque with no public fields anyway
but I've never seen the like "well what if we want to change something from being a public field to instead storing something different internally but then provide backwards compatibility through a new method which computes the old thing as a calculation on the new internal representation?" use case actually come up in practice
Richard Feldman said:
but I've never seen the like "well what if we want to change something from being a public field to instead storing something different internally but then provide backwards compatibility through a new method which computes the old thing as a calculation on the new internal representation?" use case actually come up in practice
I've had to do this before. It was miserable. 1/5 stars. Do not recommend.
oh? what was miserable about it?
Because sometimes that calculation is non-trivial or meant you had to keep data around in the data structure that isn't pertinent anymore
And sometimes you had to just throw an exception for some cases because we just could do it anymore
It _should_ have just been versioned
And had a strategy to handle the different versions and/or convert from one version to another
When I was first reading this, the usecase I was imagining was being able to have the constructor private, such that you can make strong guarantees that must be true of any instance of that type
Unfortunately if you can _update_ the fields, that no longer works
(I mean, update the fields in an unconstrained maner from outside the module, without going thru the same validation logic again)
yeah we can't support that for the same reason that we can't support private fields: it would break principal decidable type inference
I didn't follow that argument, could you give an example of something that would break?
This is what I was imagining: the "plain" record update syntax _never_ means a custom record, and MyRecord.{foo & a: 1, b: 2 } always means that (but requires that code be written in the same module)
Then, take a function accessing a field from that, but without a type annotation:
foo = |myrecord| myrecord.a + myrecord.b
The type of that would be inferred to be {a: Num, b: Num} -> Num
We'd make it so record types could always unify with custom record types
(i.e. custom record types are a refinement of plain record types, in the same way that {a: Num, b: Num}{c: Num} is a refinement of {a: Num, b: Num}o)
You could try to sneak past the requirement that such a custom record must be constructed in the module by (in another module) forcing your plain record literal to be unified with a custom record, e.g.
val = if Bool.true then
{ a: 1, b: 2}
else
MyRecord.new(42, 43)
... but we can easily recognize that as an attempt to construct a custom record outside of the proper module (post-type-checking)
In the type checker, records would have a new variable that needs to be solved for, the 'custom record name' - which unless constrained would just be a variable (and that'd be fine)
random thought that just occurred to me: even if people used nominal records for absolutely every data type (which I don't think is likely, but hypothetically), structural records would probably still be worth having just for record builders alone :big_smile:
Could you make a nominal record builder?
Like
NominalName.{ combine <-
foo: bar,
foo2: bar2,
}
I suppose, but then you'd have to define the type up front even though you'd only be using it in one place
that's a recurring annoyance for me in Rust, e.g. when I want a "named arguments" equivalent (also a place where structural records are nice outside of data modeling)
Structural records (with decidable type inference) are - more than just about anything else - the core of Roc to me
They are super convenient.
Though given that I type all functions eventually, probably not actually that important. Nominal types with type interference would be about the same but more verbose
Yeah, it feels lovely to just hit a problem without even thinking about the types, letting the compiler guide me, and then concretizing the types after the fact
Not exactly the same but inferable collections of tags is also killer
Yeah, 100%
Anthony Bullard said:
Structural records (with decidable type inference) are - more than just about anything else - the core of Roc to me
I think they're surper useful for the above use cases plus scripting, but I'm surprised you'd put them that high!
in a bigger code base, where you're choosing to write out the types of structural records with type aliases anyway (just to avoid writing out the type every time in annotations), do you still see them as very different from nominal records?
Probably not, because at that point you have named them. But I haven't built a real piece of software with Roc yet so I don't know how I would approach things. I know it's great to not have to use a named constructor to build a record everytime but I still get the benefit of typechecking
The "named args" thing is a really strong want for me in Rust when you have unobvious args at the callsite, e.g. check_value(true, 123). What do those mean? A disciplined dev will name their vars to make the callsite, but using a record means you don't have to trust the caller, they need to give context to call the function
And if the caller doesn't want to have to label everything, they can just named the args based on the field names, and punning will make the callsite about as long as it'd be sans record
Yeah, using a record (especially with optional fields) as a way to force named args is really nice. And it even reminds me of the Dart syntax for specifying named args. And between Roc and LLVM it should have little to no overhead
Since records on their own are all stack-based, it's a "zero-cost abstraction"
Yeah, but LLVM can sometimes even do struct unpacking if it's used as a function arg
Not always, but sometimes
I'm sure Brendan knows the exact rules for it
Llvm only does it with structs that contain 2 register sized elements. So very limited. That said, it will do it if only 2 fields are used from a larger struct and they are both register sized
Otherwise, it is all just const ref on the stack
So either way, not much of an impact
Structural record are very close to the core of Roc for me also. I suspect in a large system I would use nominal records for the "this is an important part of my domain" kind of types and structural records for many other instances where I just want to group some fields together in a one off way to get a job done. In Java I'm constantly frustrated by having to define and name records that are only used in one or two places for some simple intermediate purpose.
I guess if we had named args and default valued args, I probably would be fine without structural records in the long run....but I probably would still miss them for quick prototyping
I think we'd still want them for the same reason we want tuples: structural records serve many functions (pun somewhat intended) that we'd probably need multiple, smaller features to replace them, so it's better to have a single, simple, obvious feature than four little helper features (e.g. named args, etc)
This mirrors how tuples solve both matching on multiple local values and allowing multiple return values without needing two discrete language features
yeah although I think the benefit of structural records is much bigger than tuples :big_smile:
Last updated: Jun 16 2026 at 16:19 UTC