Skip to content
Crixaa
← All posts

Reserved fields need to be stripped, not ignored

The Crixaa team4 min read

A renderer computes a few things for itself — the page number, the total page count, the date it ran. It fills them in only when the caller has not supplied a value, which is the polite version of that feature and the one most people would write.

Two defaults, each defensible

The renderer's rule is fill-in-if-absent. Three keys are reserved, and each is stamped only when the incoming data has nothing for it.

KeyWhat the renderer stamps
currentdatethe date the document was generated
pagenumberthe page this element is drawn on
totalpagesthe page count of the finished document
data.currentdate ??= formatDate(new Date())
data.pagenumber ??= page.index + 1
data.totalpages ??= doc.pageCount

Nullish assignment, so a caller who genuinely wants to pass a value wins.

The editor has a separate rule, written by someone else for a different reason: seed every placeholder the template mentions with a humanised version of its key, so no field in the data panel ever opens blank.

const humanise = (key) =>
  key.split('_').map((w) => w[0].toUpperCase() + w.slice(1)).join(' ')

humanise('buyer_name')  // 'Buyer Name'
humanise('currentdate') // 'Currentdate'

Also reasonable: a panel of empty inputs tells you nothing about what the template expects.

The value that no screen ever showed

Put those together and a template with a current-date placeholder gets the literal string Currentdate stored against currentdate. The renderer then does exactly what it promised: the caller supplied a value, so it does not stamp one. The document goes out with the word Currentdate where the date should be.

The humaniser only splits on underscores, so buyer_name comes out looking like a label and currentdate becomes a single capitalised word. All three reserved keys are single tokens, and all three seed into the same kind of nonsense.

The other half is worse. Reserved fields are filtered out of the data panel, because the user is not meant to fill them — that is what reserving them means. So the seeded value sat in stored data with no row in the interface. There was no screen anywhere in the product on which the string Currentdate appeared before it turned up in a finished PDF. The user could not have found this, could not have cleared it, and had no way to know it was there.

Why the preview was right and the PDF was wrong

The preview path stripped reserved keys before sending. The generate path did not.

Same template, same stored data, two payload builders, and only one of them knew the rule. So the person checking their work saw the correct date on screen every time, which is precisely the circumstance under which nobody looks further. The preview did not merely fail to help — it certified the wrong output.

That is the general shape of it: when two call sites build the same payload, one of them is older, and the older one is missing whatever was learned since.

Strip at the boundary

There are two ways to fix a rule a caller forgot: tell every caller to remember it, or do it in the one place they all pass through.

const RESERVED = new Set(['currentdate', 'pagenumber', 'totalpages'])

export function stripReserved(data) {
  return Object.fromEntries(
    Object.entries(data).filter(([key]) => !RESERVED.has(key.toLowerCase())),
  )
}

This runs at the render entry point, right after the incoming data is reshaped and before any placeholder is resolved. Not in the preview handler and the generate handler and the batch handler — once, below all of them, where a caller written next year inherits it without knowing it exists.

The verb matters too. Ignoring a reserved key leaves the bad value in place. Stripping removes it, so fill-in-if-absent gets the absence it was written for.

Validating on write would not have fixed it

The tempting alternative is to reject reserved keys at save time with a 422. Better error message, and it does not solve the problem.

Templates saved before the check still hold the seeded value. So do templates saved before a key joined the reserved list, and that list grows: the day you reserve a new name, every row already using it becomes wrong. Validation on write protects rows written after the validator shipped. Stripping on read fixes the history too, which is why it goes in even if you add the 422 as well.

One caveat. If you genuinely want to control the date — an invoice dated into the previous GST period, say — that is an ordinary field with a name of your choosing, invoice_date, bound like any other. Reserved means the renderer owns it.

The habit worth building

When you add a defaulting rule, find the other defaulting rules and ask what happens when both fire on the same key. Fill-in-if-absent and seed-everything-so-nothing-is-blank are each obviously correct in isolation, were written months apart by people solving unrelated problems, and are actively hostile to each other. No review would have caught it, because neither change was wrong.

And when a rule has to hold across several call sites, put it below them rather than in them. A convention every caller must remember is not a rule. It is a bug with a waiting period.

Try it on your own document

Design a template in the browser and generate a real PDF — free, no card.