← All notes
CMS Implementation6 min

The field we dropped twice: explicit mappers are a bug factory

Hand-written field-by-field mappers are where seed data silently sheds fields. We dropped the same class of field twice on one engagement; the second time the hidden flag never mapped, and taken-down projects reappeared publicly. Prefer spreading identical shapes, exhaustive types on the seed path, and a read-back diff — not another careful mapper.

The most boring function in the codebase

The mapper reads the seed JSON and builds a document object, field by field. Adding a field to the system means touching three places: the seed, the schema, and the mapper. The first two are the ones you are thinking about, because they are the ones the feature is about. The third is plumbing.

The failure is not a crash. The document is created successfully with one field missing. Sanity, like most document stores, is perfectly happy to accept a document without an optional field. The site renders. The field is just empty.

That is the worst possible failure shape: silent, valid, and only visible if you happen to look at the specific thing that is now missing.

Why nothing caught it

TypeScript would have caught it if the target type required the field. It did not, because CMS document types are almost all optional fields by nature. The schema permits partial documents on purpose, and we had written the type to match that reality.

The seed had the field. The schema had the field. The site had a fallback for an empty field, which is normally a virtue and here meant the page looked fine. Every individual piece was doing exactly what we had asked it to do.

The general lesson: when you have a translation layer between two representations of the same data, an omission in the translation is invisible from either side. You have to compare across the boundary to see it at all.

// Destructuring every key makes the mapper total: a field added
// upstream is a compile error here, not a silent omission.
function toProject({
  slug, title, listTitle, category, client, year, summary,
  stack, links, credits, screenshot, hidden, featured, rank, tile,
}: SeedProject): ProjectDoc {
  return {
    slug, title, listTitle, category, client, year, summary,
    stack, links, credits, screenshot, hidden, featured, rank, tile,
  }
}
We shipped the same dropped-field bug twice before writing it this way.

The second time, it shipped

The first time we dropped a field it was cosmetic, and we caught it in review. We fixed it, noted it, and moved on without changing the mechanism — which is the actual mistake in this story.

The second time, the dropped field was the hidden flag. Hidden projects are supposed to stay off the public list. With the flag missing from the mapped documents, every hidden project came back, including work a client had asked us to take down. That is not a rendering bug. It is a broken commitment.

The fix took two minutes. The reason it happened twice took longer to accept. We had treated the first occurrence as a mistake rather than as evidence about the design. A bug you can reproduce by being slightly distracted is a design problem wearing a mistake's costume.

Removing the class, not the instance

The first option is not to map at all. If the seed shape and the document shape are the same, spread the object and set the type and the ID. Fields you never named cannot be dropped, and the mapper stops being a place where knowledge lives. This works when you control both ends, which for a seed you wrote yourself, you do.

The second is to make the type exhaustive. If the document type requires every field and the mapper constructs it as an object literal, the compiler catches the omission immediately. That means giving up the everything-is-optional convenience on the seed path specifically. The CMS can still accept partial documents; it is the seed writer that gets held to a stricter shape.

Both of these move the problem from remembering to being told. That is the criterion for a good fix here. After the change, could a distracted person still make this mistake? If yes, keep going.

And the checks that catch what types cannot

Types do not catch a field mapped to the wrong key. Both sides typecheck happily; the value just lands somewhere else. So after writing, read the documents back and compare them against the source field by field, printing mismatches. It is a dozen lines, it runs as part of the seed script, and it catches every variant including the ones the compiler is blind to.

The other check is on the invariant rather than the mechanism. What actually mattered in our case was not that the mapper copied the hidden field correctly. It was that no hidden project appears in the public list. Assert that against the data the page renders and the test survives every future change to how mapping works, including the change where mapping stops existing.

We wrote both of those after the second occurrence. We should have written them after the first. The tell that you are about to repeat a bug is that you fixed the instance and felt relieved.

When a mapper is still the right answer

The argument so far is one-sided, so here is the counterpoint. Sometimes the two shapes genuinely differ and no amount of spreading will save you. On our own migration the source was a WordPress work custom post type read over the REST API, and its shape had nothing in common with the documents we wanted: rendered HTML in fields that should have been plain text, taxonomy terms as arrays of IDs, dates in a format nothing else used, and metadata in a flat bag of keys. A mapper was not optional there. Translation was the entire job.

What changes is how you write it. Destructure the whole source object at the top of the function, naming every key, and let the compiler complain about anything left over. Then a field appearing upstream is a build error instead of a silent omission. The mapper stops being a list of things you remembered and becomes a list of things you were forced to consider.

And keep it in one file, with the source shape and the document shape imported side by side. Part of why we shipped that bug twice is that the mapping lived somewhere you did not naturally pass through while doing the work. Code you have to go looking for is code you will forget to update.

Questions

Why don't TypeScript and CMS schemas catch dropped mapper fields?
CMS document types are mostly optional by design, so partial documents typecheck and Sanity accepts them. The site may even fall back gracefully. An omission in the translation layer is invisible from either side until you compare across the boundary.
What is a safer pattern than listing every field in a mapper?
If seed and document shapes match, spread the object and set type/id so unnamed fields cannot be forgotten. If shapes differ, destructure the whole source and force leftover keys to be compile errors. Then read documents back and field-diff against the source.
When is an explicit mapper still necessary?
When the source shape truly differs — WordPress CPT HTML, taxonomy ID arrays, odd date formats. Translation is the job. Write it so every upstream key is considered, not so every remembered key is copied.

Sources

  1. Sanity — schema types
  2. Sanity JS client — createOrReplace
  3. TypeScript — excess property / exactness patternsUse required object literals on seed writers even when CMS fields stay optional.

Have something to build?

Tell us what you're working on and we'll tell you honestly whether we're the right fit.

Work with us