How-to › Model, configure and generate

How to make a Handlebars template produce byte-identical output#

Sort the model, keep helpers pure, normalise line endings and compile strict, so a Handlebars template renders the same bytes everywhere, checked with sha256sum.

Audience
Library maintainer
Level
intermediate
Topic
Engineer templates and AST transforms
Languages
TypeScript
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
EJSYou want JavaScript in the template and will police it in reviewAny expression runs, so a Date.now() in a template is legal and invisible until the diffThe template is edited by people who should not be writing JavaScript
HandlebarsHelpers 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 handedYou need loops with logic the built-in helpers cannot express
mustache.jsLogic-less templates shared with Mustache implementations in other languagesNo helpers, so formatting moves into the view, and a function placed in the view still runsYou 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&lt;string, string&gt;, 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&lt;string, string&gt;
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&lt;string, string&gt;
mustache.js   identical    different      Record&lt;string, string&gt;
EJS           identical    different      Record&lt;string, string&gt;

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.

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

  1. The coreutils manual files sha256sum under “sha2 utilities” and opens the entry by calling it a legacy interface to the more modern cksum. 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

  2. ECMAScript specifies OrdinaryOwnPropertyKeys in 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 keys 2 and 10 come out as 2 then 10 whatever order they were added in, which is the one place insertion order loses to arithmetic. ↩︎ Back to text

  3. The gitattributes manual says that when text is set and neither core.eol nor core.autocrlf is, the default is eol=crlf on Windows and eol=lf on 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.