How to do user facing records in 2024

is there even a non opinionated option? every option will make a part of the interface awkward you can’t support OverloadedRecordDot, record selectors and every optics library simultaneously, that said there is an option where there is maximum freedom, use NoRecordSelectors and provide a Lens for every library you want to support in a separate sublibrary, any one that wants a record selector can easily get one by using OverloadedRecordDot

Hmm, it seems like the take-away here is that there is no agreed upon best practice yet.

DuplicateRecordFields seems to be liked though!

Ah, as in actually using the multi-library package feature for once?

It hadn’t even occurred to me that you could use such a feature to let users opt in to more dependencies for more features without a second package.

Well, you can… just not necessarily first-class support. Either your fields or your lenses need to have a prefix, so you have to pick a favorite there. OverloadedRecordDot is I think pretty unintrusive and doesn’t get in the way of the other options it would seem, but it’s a bit of work to show examples of how to use all three in your documentation, so it will be very tempting to pick a favorite to recommend.

Good point… in my zeal to bring up as many problems I can think of with interacting record extensions I may have imagined one :sweat_smile:

Ah, there’s another thing. When you use DuplicateRecordField do you put all your records into separate modules so selector functions can always be disambiguated by qualified imports, or do you just throw records with duplicate fields in the same module, let the compiler generate selectors and offer them “as is” and leave it up to the user to find an alternative solution when field selectors are ambiguous?

I suppose if duplicate field selectors are defined in the same module then maybe users can avoid ambiguity with:

import M (Foo, Bar)
import M qualified as Foo (Foo(..))
import M qualified as Bar (Bar(..))

I’ve only just thought of doing that…

Yeah I usually have duplicate record fields enabled for the same reason that @michaelpj mentioned, where I have a top-level module as a namespace that imports multiple modules under it, and it’s likely there are duplicate selectors within that namespace.

How users get around ambiguity is up to them in this case. I still expose the child modules if they want to avoid importing the entire namespace. Your example is also an option, and I think it’s generally a good practice.

I think the “just record selectors and Generic instances” is the non-opinionated option. You export something that is pretty much “normal-Haskell”:

  • There are record selectors
  • They have normal names (you don’t prefix them)
  • They can be used as normal (including with OverloadedRecordDot if your users want)
  • You don’t depend on any optics library since you’re not providing any optics
  • Users who want optics can get a pretty good experience still using generic-lens or generic-optics

Yes, this is IMO one of the key usecases for multiple libraries. e.g. lsp-types has a sublibrary for the quickcheck instances, so we can publish them together but people who don’t want them don’t need to incur the quickcheck dependency. It’s great.

4 Likes

There’s a snag when trying to use sublibraries to make optics dependencies optional.

Ideally I’d want to make use of optics-th's templates for creating lenses for my records, and I’d also like to be able to use opticsLabelOptic tech to refer to these lenses with overloaded labels.

Alas, this requires defining instances of the LabelOptic class, which the templates do for you, but to put these instances in a sub-library they have to be orphan instances.

Is it just not that big of a deal in practice that I should define them anyway?

I wouldn’t worry about orphan instances in that case.

Maybe a more worrysome snag is that due to this cabal-bug Per-component dependency solving · Issue #4087 · haskell/cabal · GitHub essentially I don’t think the dependency on optics in such a sublibrary is actually optional.

As an additional data point: I still use the regular. _foo names + lens (sometimes makeLenses or makeClassy, or hand written lens functions). I find the optics approach to use % instead of . too noisy.

1 Like

I don’t understand why sublibraries per se are beneficial here, rather than just having separate libraries.

This is a bigger topic, but I think basically they’re just easier.

  • One cabal file, so you don’t have to repeat metadata and you can use common stanzas across them
  • One version, so you don’t have to think about how to version them independently
  • One package, so you don’t have to release and upload them separately

Flipping it around: why would you use a separate package when you have a sublibrary? The main thing that a separate library gets you is a separate version… but often you don’t need or want that.

(There are of course still tooling issues, which are legitimate reasons to avoid them, but conceptually I think they’re pretty great.)

3 Likes

As a follow-on idea to this, you could expose your getter-setter pair in a private optics-compat sublibrary or similar and then have mything-lens and mything-optics just construct the native optics with the constructors (e.g., lens, in both libraries). In this way, you can hide the particularities from a user while also providing the tool support. If I remember how OverloadedRecordDot works, you could even use these to implement HasField instances and provide opt-in support for that syntax as a library at the cost of some semi-orphans.

Amazonka has similar concerns (generating lots of records from service definitions), exports records with no leading underscore and no other prefix, and I think it works well there. This needs -XDuplicateRecordFields in the modules where the record is defined (for GHC <= 9.6) and in any module which collects and re-exports them (for GHC >= 9.8, if you do that).

Library clients are expected to use whatever record technology they prefer. The Generic instance allows generic-lens/generic-optics, but because all the constructors are exported normal selectors/updates and dot syntax are also usable.

5 Likes

Based on a number of comments in this thread, I feel like the answer to “how to do user facing records in 2024” is “Do what amazonka-2.0 does”

1 Like

I wish I could take credit, but that predates my maintainership. It does seem to have worked well.

2 Likes

My answer is no. If you expect the qualified imports, you still don’t need to scatter your records across separate modules. The disambiguation requires different module prefixes, not different source modules. So instead of

import qualified Library.Record1 as Record1
import qualified Library.Record2 as Record2

the user can say

import qualified Library(Record1(..)) as Record1
import qualified Library(Record2(..)) as Record2

with the same effect.

I thought it is interesting that, as you noted, amazonka was affected by the DuplicateRecordFields behaviour change though. I wonder, how things would have been if Amazonka had used prefixed record fields. Then, it wouldn’t have needed this extension, right? Amazonka-2.0 could have been compatible with GHC 9.8/9.10.

Not necessarily. The generator for Amazonka 1.6.1 and lower generated abbreviations for record names and used them for field prefixes, but it’s not impossible to have two packages generate similar field names. But it would have made it a lot less common.

Say I have a record exported, data Foo { a :: Int }, and I add a new record field. Now it’s data Foo { a :: Int, b :: String }. I want to have consumers explicitly ignore the new field if they don’t want it. How can I enforce this? I’d have to encourage them to use positional record syntax (since the pattern Foo fieldA would no longer work), but that’s not good. Isn’t there a way that avoids positional syntax, but still requires matching on all fields?

A few options:

3 Likes
data Foo = Foo Int String
a (Foo v _) = v
b (Foo _ v) = v

200+ IQ

Please think about versioning.

2 Likes