Haskell World logo Haskell WorldWrite code, build worlds
Programming

Haskell Record Syntax Without the Field Name Headaches

You add a second record type with a field called name , and GHC refuses to compile because the first record already owns that name.

Developer at a desk comparing two Haskell record definitions side by side on a wide monitor

You add a second record type with a field called name, and GHC refuses to compile because the first record already owns that name. Haskell record syntax is fine for one type in one module, but the moment two types share a field you are renaming things to userName, projectName and teamName just to keep the compiler quiet.

This guide walks through what record syntax actually generates, why the clashes happen, and which of the modern fixes are worth turning on in a real codebase. I have migrated two mid-sized services (around 40,000 lines each) through these extensions, and the order you adopt them in matters more than most blog posts admit.

What Haskell Record Syntax Really Generates

When you write a declaration like data User = User { name :: Text, age :: Int }, GHC does three things. It creates the constructor User. It creates a top-level function name of type User to Text. And it creates a top-level function age of type User to Int. Those selector functions live in the module namespace like any other function you define.

That last detail is the whole problem. A selector is not scoped to its type. It is an ordinary function, so a second record with a field called name tries to define a second top-level function with the same identifier, and the module will not compile. Nothing about this is mysterious once you see the selectors as plain functions, which is also why the classic workaround was prefixing every field with the type name.

Record syntax also gives you two other features people forget about:

  • Record construction with named fields, so User { name = "Ana", age = 31 } reads clearly and survives reordering of fields.
  • Record update, written as user { age = 32 }, which copies the value and replaces one field.
  • Pattern matching by field, so a function can match User { age = a } and ignore everything else.

Each of these interacts differently with the extensions below, which is why a field name fix that looks complete in one place can still break update syntax somewhere else.

The Prefix Convention and Where It Breaks Down

The traditional answer is userName, userAge, projectName, projectOwner. It works on every GHC version, it plays nicely with tooling, and error messages stay readable. For a small library with five record types, I would still pick it.

It breaks down at scale for a boring reason: JSON. If your API returns a field called name, your Aeson instances now need a fieldLabelModifier that strips the prefix, and every type needs that modifier configured correctly. On one service we had 63 record types, and three of them silently serialized with the wrong field names because someone copied an instance and forgot to change the prefix length from 4 to 7. If you want the full picture of how those instances get derived, the walkthrough on parsing JSON in Haskell with Aeson covers the options in detail.

Fixing Field Name Clashes With Modern Extensions

Since GHC 9.2, three extensions together give you something close to what other languages call dot access. Turn them on in your cabal file under default-extensions so every module agrees.

  1. DuplicateRecordFields lets two records in the same module declare a field with the same name. On its own it does not help much, because using the bare selector name becomes ambiguous.
  2. OverloadedRecordDot lets you write user.name and project.name. GHC resolves the field through the HasField class based on the type of the value, so the ambiguity disappears.
  3. NoFieldSelectors stops GHC from generating the top-level selector functions at all. This is the piece most teams skip, and it is the one that actually frees up the namespace, so a local variable called name no longer shadows a selector and triggers warnings.

With all three on, the earlier example becomes data User = User { name :: Text, age :: Int } and data Project = Project { name :: Text, owner :: User }, and code like project.owner.name compiles and reads the way you expect.

One trap: record update with a duplicated field still needs to know which type you mean. If GHC cannot infer the type from context, add a type annotation on the value being updated. Recent GHC versions warn about ambiguous updates, and that warning is a preview of an error in a future release, so fix them as they appear rather than silencing them.

Why You Probably Do Not Need Lenses Yet

A common piece of advice says that once records get awkward you should reach for the lens library and never look back. I disagree for most application code written today.

Lenses solve nested updates elegantly. Changing a deeply nested field with plain record update syntax is genuinely ugly, three levels of braces for one value. But lens pulls in a large dependency tree, adds Template Haskell for generating optics, and makes error messages noticeably harder for newer team members. On our second migration, the build time for a clean compile dropped from about 6 minutes 40 seconds to just over 5 minutes after removing lens from a service that used it for maybe a dozen nested updates.

My rule now: use OverloadedRecordDot for reading, plain record update for shallow writes, and write a small helper function for the two or three deeply nested updates you actually have. If you find yourself writing more than ten of those helpers, that is the point where optics earn their cost. The lighter optics package generic-lens or optics are both reasonable at that stage.

The same reasoning applies to wrapping fields in distinct types. Records pair nicely with the ideas in newtype patterns that prevent production bugs, because a field typed as UserId instead of Int catches mistakes that no naming scheme can.

A Migration Order That Avoids Pain

Turning everything on at once in a large codebase produces hundreds of errors, and most of them are noise. This sequence kept each pull request under about 300 changed lines:

StepChangeWhat breaks
1Enable OverloadedRecordDot everywhereAlmost nothing; old selectors still work
2Rewrite reads module by module to use dot accessNothing, purely stylistic
3Enable DuplicateRecordFields and drop prefixes per typeAmbiguous updates; add annotations
4Enable NoFieldSelectorsAny remaining bare selector use
5Remove Aeson fieldLabelModifier hacksSerialization tests catch mistakes

Step 5 needs golden tests on your JSON output before you start. Without them, a renamed field changes your API contract and nobody notices until a client complains.

FAQ

Is Haskell record syntax bad?

No. It is limited by the fact that field selectors are top-level functions, which causes name clashes across types. For small modules it is perfectly fine, and the extensions shipped since GHC 9.2 fix most of the real complaints.

Does OverloadedRecordDot slow down my code?

Not measurably. Field access through HasField is resolved at compile time and optimizes down to the same selection as a regular selector. Any cost you see will be in compile time on very large modules, not at runtime.

Can I use dot syntax with records from other libraries?

Yes, as long as the field selectors are in scope. If a library uses prefixed names, you write value.libPrefixField, which works but looks odd. You cannot rename another package's fields without wrapping the type.

Should beginners learn lenses before records?

No. Get comfortable with plain records, pattern matching and the Maybe type for optional fields first. Lenses make far more sense once you have felt the pain of nested updates yourself.

SR
Sonja Reiter

Sonja Reiter contributed to three major open source projects and started writing documentation when she noticed most tooling guides were written by people who had never taught anyone to use them. She covers build tools, package ecosystems, and the invisible quality-of-life differences between development setups.

More posts by Sonja

More in Programming

Haskell code showing a function returning Just a value and Nothing for a missing case Programming

How the Maybe Type Replaces Null in Haskell

Most mainstream languages let any reference be null, which means any value might secretly be absent, and the only way to find...

Finnian BurkeOct 22, 20265 min read
01doorganico 084goad 3hot8 4465456 after5internprogram ahandfulofblessings ahmadpc ahomcybersecurity allodialresources annodex antiquesandperiodcollectibles aotc asianworldkashmir beedlectrics bloggertemplatefree bluefeatheragency bodyweightsolution campaign08blog carfixadvisor catstreetcollective cbstvsports cnfanspurchasing committeeof500yearsofdignityandresistance covertopsdjs cs-kaiyuangame cuestacomunicaciontotal cxoutlet dancebetgame deskgearhub destinyvillastay detectmagazine di33 diamondsteelbuildings donmo dyanadropship emarsysfinance emimattl escapechan eskitarih evautoplay everythingweddings-orlando farmzone feedviewerapp foxstudiosscratch gamevui123 gleasongrade greetingsday hacklabasu iw-kaiyun jagoanproperti jilidskripsi joltbusinessfinance juegodepokergratis juegospucca kaeru-festival latestmehndidesigns lestanzediottaviano litemagazines localcommunityhero lustrsazan mariashebets menswheels newwebstechs1 nikkit ootheday oxigenvpn plurfitness prefetial probudgetcarpetcleaning psychicmrsmartin pyaten qvantor rejoignezlogis rexzbot riselayouts ruchin rwxqsgd5 sakaltime shibiaowang shop-bot skrtips strcoding tetapatrium topselller uneplanteartificielle vipertechcr webtech1223411 wo-kygaming yamaha-mekar yeknava yoybuysheets zenunwa yamaha-mekar jilidskripsi ankertoto ankertoto ankertoto ankertoto