Your generator renders a TypeScript client from a Handlebars template, and the file it commits
differs on every machine. The diff on a colleague’s laptop shows every line changed, because
the endings are CRLF. The CI run reorders two interfaces. A generatedAt comment moves the
build id every time. Nobody can tell a real change from noise.
What you get
You will end up with a renderer whose output hashes the same under sha256sum on two runs and
two machines, plus a test that fails when any cause of drift returns. This is for you if a
generated file shows up in every diff.
Short answer
Compile with strict: true, noEscape: true and preventIndent: true, and register only
helpers that are pure functions of their arguments. Sort every object in the model before
rendering, because {{#each}} walks keys in the order they were inserted. Normalise the
rendered string to LF with one trailing newline before writing it, and pin the template files
to eol=lf in .gitattributes. Two renders then hash the same under sha256sum.
You will need
Node 22 or later, and a Handlebars template that generates source code rather than HTML.
Verified 2026-09-25 against Node 22.22.2, handlebars 4.7.9, mustache 4.2.0 and ejs 6.0.1,
all installed from npm in the sample directory. sha256sum comes with
GNU coreutils,1
and any other SHA-256 tool prints the same digest.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| EJS | You want JavaScript in the template and will police it in review | Any expression runs, so a Date.now() in a template is legal and invisible until the diff | The template is edited by people who should not be writing JavaScript |
| Handlebars | Helpers you register, a strict mode, and whitespace you control with ~ | Every helper is yours to keep pure, and {{#each}} over an object follows the key order it was handed | You need loops with logic the built-in helpers cannot express |
| mustache.js | Logic-less templates shared with Mustache implementations in other languages | No helpers, so formatting moves into the view, and a function placed in the view still runs | You need whitespace control finer than the standalone-line rule |
All three engines are deterministic when the model is sorted and nothing in the render reads a clock. What differs is who enforces that. EJS enforces nothing, and buys you any loop you can write. Handlebars enforces the split between template and helper, and buys you whitespace control. mustache.js removes helpers, and buys you a template another language can render, at the price of doing every sort and every join in the view.
Sort the model, because the template will not
{{#each}} over an object visits keys in the order
ECMAScript defines
for own property keys: integer keys first in ascending numeric order, then string keys in the
order they were created.2 A model parsed from an OpenAPI document on one machine and from a
cached copy on another can differ in that order and in nothing else.
// Sort every object's keys, recursively, and leave arrays alone. An object is a set and an
// array is a sequence: parameter order is part of the signature the file declares.
export function sortModel(value) {
if (Array.isArray(value)) return value.map(sortModel)
if (value && typeof value === 'object') {
return Object.fromEntries(Object.keys(value).sort().map((k) => [k, sortModel(value[k])]))
}
return value
}
The rule in the comment is the one that matters. types and operations are maps, so their
order carries no meaning and gets sorted. params is an array, because
listOrders(status, limit) and listOrders(limit, status) are different functions. Sort a
sequence and you have changed the generated API, deterministically.
Compile with the three options that matter
Handlebars compiles templates for HTML by default. Three compile options turn it into a source generator.
// strict: a missing field throws instead of rendering as an empty string.
// noEscape: generated TypeScript is not HTML, so `Record<string, string>` stays as typed.
// preventIndent: a partial's indentation comes from the partial file, not from the call site.
export const OPTIONS = { strict: true, noEscape: true, preventIndent: true }
strict is the one that finds bugs. Without it, an operation with no returns renders
Promise<> and the TypeScript compiler reports the problem three steps later. With it, the
render throws "returns" not defined in [object Object], naming the field. It does not check
a helper’s arguments, so a missing params reaches signature as undefined, and the helpers
below check their own. noEscape keeps Record<string, string> from becoming
Record<string, string>, which is what every angle bracket in a generated type turns
into otherwise. preventIndent stops an indented partial call from indenting every line the
partial emits, so the partial file owns its own layout.
The method partial is called once per operation, and the tilde in {{~/each}} is
whitespace control: it
removes the newline the loop would otherwise leave after each method.
export class {{service}}Client {
constructor(private readonly baseUrl = '{{baseUrl}}') {}
{{#each operations}}
{{> method name=@key op=this}}
{{~/each}}
The partial carries its own two-space indentation, which preventIndent leaves alone.
{{name}}({{signature op.params}}): Promise<{{op.returns}}> {
return this.request('{{op.method}}', '{{op.path}}', {{args op.params}});
}
Keep every helper a function of its arguments
A helper runs at render time with whatever the process can see, so a helper is where a clock
gets in. Date.now(), Math.random(), os.hostname(), process.pid and the current
working directory all produce output that is correct and different every time.
// One line ending, and exactly one at the end. A template checked out with CRLF renders CRLF,
// so the output is normalised rather than the checkout trusted.
export function normalise(text) {
const lf = text.replace(/\r\n?/g, '\n')
let end = lf.length
while (end > 0 && lf[end - 1] === '\n') end--
return `${lf.slice(0, end)}\n`
}
// Every helper is a pure function of its arguments. No clock, no random source, no host name.
// strict does not check a helper's arguments, so a missing list arrives here as undefined, and
// each helper checks its own and names the field.
const list = (params) => {
if (!Array.isArray(params)) throw new Error(`"params" must be an array, got ${typeof params}`)
return params
}
export const helpers = {
signature: (params) => list(params).map((p) => `${p.name}: ${p.type}`).join(', '),
args: (params) => (list(params).length ? `{ ${params.map((p) => p.name).join(', ')} }` : '{}'),
}
normalise is the other half of the line-ending fix. The .gitattributes below stops git
from converting the template on checkout, and the normalisation stops a template that arrived
with CRLF anyway, through a zip file or an editor setting, from rendering CRLF. The
gitattributes documentation sets eol=crlf as the
default on Windows when text is set and core.autocrlf is not,3 so the file is not
optional.
# Templates render whatever line ending they were checked out with. Pin them, so a
# checkout on Windows with core.autocrlf=true renders the same bytes as one on Linux.
*.hbs text eol=lf
*.mustache text eol=lf
*.ejs text eol=lf
*.json text eol=lf
The render itself is one line: sort, compile with the options, normalise.
export function render(data, { template = readFileSync(here('templates/client.hbs'), 'utf8'), engine = createEngine(), options = OPTIONS } = {}) {
return normalise(engine.compile(template, options)(sortModel(data)))
}
Render twice and compare the digests
Two renders of the same model, hashed with sha256sum.
node render.mjs out/a.ts && node render.mjs out/b.ts && sha256sum out/a.ts out/b.ts
3932e839e675734e26f64449329e8546e4c9fc2919ac35ad0329acd1d31f9739 out/a.ts
3932e839e675734e26f64449329e8546e4c9fc2919ac35ad0329acd1d31f9739 out/b.ts
Run the same command on a second machine and compare the digests. Two runs on one machine prove the helpers are pure. Two machines prove the line endings and the key order are under control, because those are the two things a second machine changes.
Check it worked
The test suite pins each cause of drift separately, so a regression names itself.
test('a template checked out with CRLF renders the same bytes', () => {
const crlf = TEMPLATE.replace(/\n/g, '\r\n')
assert.notEqual(crlf, TEMPLATE)
const out = render(model, { template: crlf })
assert.equal(out, render(model))
assert.ok(!out.includes('\r'))
})
node --test render.test.mjs
1..9
# tests 9
# suites 0
# pass 9
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 165.332555
The eighth test registers a helper that calls Math.random() and asserts that two renders
differ. A test that only proves the good path passes cannot tell you the guard is doing
anything.
When it goes wrong
Each safeguard removed in turn against the same model and template.
node pitfall.mjs
keys reversed, no sort different
keys reversed, sortModel identical
CRLF template, as rendered different, 30 carriage returns
CRLF template, normalised identical
helper calls Math.random() different
noEscape false renders Record<string, string>
noEscape true renders Record<string, string>
preventIndent false " first\n second\n"
preventIndent true " first\nsecond\n"
The output differs and the file compiles. Reversed keys produce a valid client whose interfaces are in a different order. A CRLF template produces a valid client with 30 carriage returns in it. The build is green in both cases, which is why the check has to be a digest and not a compiler.
The digest matches on your machine and differs in CI. Look at what the helper can see: an
environment variable, a package.json version read from disk, the path of the checkout.
Anything read from outside the model is an input the digest does not know about, so pass it in
through the model or leave it out.
The digest matches, but the diff is still noisy. The comparison is being made after a formatter ran on one side and not the other. Format inside the generator or in neither place, and compare the bytes you wrote.
What the other two engines change
The same interfaces rendered through Handlebars, mustache.js and EJS, twice each, from the sorted model.
node compare.mjs
handlebars 4.7.9 | mustache 4.2.0 | ejs 6.0.1
engine pure view impure value default output for "Record<string, string>"
Handlebars identical different Record<string, string>
mustache.js identical different Record<string, string>
EJS identical different Record<string, string>
all three rendered the same 195 bytes from the sorted view: true
Three things to read off that. All three engines escape HTML by default, so each needs its
own switch: noEscape here, the triple mustache {{{type}}} or an overridden Mustache.escape
in mustache.js, and <%- in EJS. All three render a different value when a random source is
reachable, and mustache.js reaches it through a
lambda, a function placed in the view. So no
helpers does not mean no impurity; it means impurity in a different file. And mustache.js iterates
arrays only, so the sort that Handlebars lets you forget is one you cannot skip: the view has
to be built as sorted arrays before the template sees it.
When not to do this
Do not sort arrays. A parameter list, a middleware chain and a list of enum members are all
sequences, and sorting them changes the program the file describes while making the digest
look stable.
Do not set noEscape on a template that produces HTML or Markdown that a browser will render.
The option exists because generated source code is not HTML, and it removes the one defence
against a field value that contains a tag.
Do not put a build id, a timestamp or a host name into the generated file to make it traceable. Put it in the commit message or in a sidecar file the digest does not cover. A file that carries the time it was written is different every time it is written, by construction.
Do not trust .gitattributes alone. It governs checkouts through git and nothing else, so a
template that reaches the build through a zip, a copy or a container image can still carry
CRLF. Normalise the output and keep the attribute file, because each covers what the other
does not.
Do not turn on strict in a template that relies on {{^missing}} inverse sections. The
compile options documentation says strict mode disables them unless the field is present in
the source object, so the render throws where the template meant to branch.
Related how-tos
Last verified
Verified 2026-09-25 against Node 22.22.2, handlebars 4.7.9, mustache 4.2.0 and ejs 6.0.1,
installed from npm in the sample directory. Every output block is what the command preceding it
printed. The two-machine claim was checked between two runs in one checkout and one run from a
copy with CRLF templates, not on a second operating system.
Footnotes
-
The
coreutilsmanual filessha256sumunder “sha2 utilities” and opens the entry by calling it a legacy interface to the more moderncksum. The same page says the four SHA-2 commands print the untagged output format, which is the two-space form shown earlier. It does not say what to call a tool that is legacy and still the one everyone types. ↩︎ Back to text -
ECMAScript specifies
OrdinaryOwnPropertyKeysin three passes: array indices in ascending numeric order, then string keys in ascending order of creation, then symbols in the same order. So a model keyed by numeric ids sorts itself and a model keyed by names does not, and a template cannot tell which kind it was handed. The keys2and10come out as 2 then 10 whatever order they were added in, which is the one place insertion order loses to arithmetic. ↩︎ Back to text -
The
gitattributesmanual says that whentextis set and neithercore.eolnorcore.autocrlfis, the default iseol=crlfon Windows andeol=lfon all other platforms. A generated file’s bytes therefore depend on the operating system that checked out its template, unless somebody writes down that they should not. ↩︎ Back to text