A template engine renders whatever you hand it. Misspell a key in the payload and you usually do not get an error, you get a blank line or the literal word undefined in a lease that is already sitting in the tenant's inbox. Put a JSON Schema check in front of the generation call so bad data fails at your own API boundary instead of inside a finished document.
const Ajv = require("ajv")
const addFormats = require("ajv-formats")
const ajv = new Ajv({ allErrors: true })
addFormats(ajv)
const leaseSchema = {
type: "object",
additionalProperties: false,
required: ["tenantName", "monthlyRent", "startDate"],
properties: {
tenantName: { type: "string", minLength: 1 },
monthlyRent: { type: "number", exclusiveMinimum: 0 },
startDate: { type: "string", format: "date" },
petDeposit: { type: "number", minimum: 0 }
}
}
const validate = ajv.compile(leaseSchema)
function assertRenderable(data) {
if (validate(data)) return
const problems = validate.errors.map(
(e) => `${e.instancePath || "(root)"} ${e.message}`
)
throw new Error(`Lease data rejected: ${problems.join("; ")}`)
}
// call this before you hand the payload to the generation API
assertRenderable(payload)Pass it an empty tenant name, a rent of zero, and startDate misspelled as startDat, and it throws an Error whose message is:
Lease data rejected: (root) must have required property 'startDate'; (root) must NOT have additional properties; /tenantName must NOT have fewer than 1 characters; /monthlyRent must be > 0Four pieces are doing the work. The required list catches the field nobody sent. Setting additionalProperties to false catches the typo, which is the failure a template will never report for you, since a stray startDat just sits in the payload unused while the real merge field renders empty. minLength and exclusiveMinimum reject values that are present but useless, like a blank name or a rent of zero. And allErrors tells Ajv to collect every failure instead of returning after the first one, so one call tells the caller everything that is wrong rather than making them fix errors one round trip at a time.
Two caveats
First, Ajv ships no format validators of its own, and it throws on an unknown format at compile time rather than quietly skipping it. Drop the addFormats line and the snippet above dies before it validates anything:
Error: unknown format "date" ignored in schema at path "#/properties/startDate"So install ajv-formats alongside ajv. If you would rather not add the dependency, swap format for a pattern, or pass formats with the format name set to true (for example new Ajv({ formats: { date: true } })) to tell Ajv to accept the keyword and ignore it.
Second, the schema is a second copy of your template's data contract, so it drifts. Keep it beside the template and version the two together: when someone adds a merge field to the document, the schema change belongs in the same commit. A schema one release behind the template will cheerfully pass data the template can no longer render, which puts you back where you started.
Back to All Questions