The template for your client’s dispatch table has grown three helpers, a view file that computes every name in advance, and a comment explaining the trailing separator. Somebody adds an operation whose name carries a dot. The template writes it without complaint, and the broken file reaches your users’ build before anyone reads it.
What you get
You will end up with the computed section written as a function over the typed model, and a fixture that pins its formatted output. This is for you if you maintain a generator and one template has stopped being a template.
Short answer
Write the section as a function from the typed model to source text. Derive every name in code, refuse a name that cannot be an identifier before anything is written, and loop over the operations. Then format the output with Prettier and assert that it equals a committed fixture. A component hides the shape of its output behind code, and the fixture puts that shape back where a reviewer can see it.
You will need
Node 22.18 or later, which runs the .ts files here directly,
and a typed model of your API: the entities, and for each one its operations with a method and a
path. Verified 2026-09-24 against Node 22.22.2, mustache 4.2.0, handlebars 4.7.9, ts-morph 28.0.0,
prettier 3.9.9 and jostraca 0.39.0. The section generated here is what a typed call(op, args)
needs: a union of the operation names, a method-and-path table, and one overload per operation.
Voxgig maintains sdkgen. This page compares its TypeScript components with Mustache and Handlebars templates, with a plain function over the model, and with a ts-morph transform run after generation.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Handlebars templates | The section is mostly literal text with a loop or two, and you want helpers for the names | Helpers are code in another file, and @../last is the kind of path a reviewer has to look up | The section computes more than it copies |
| Mustache templates | The template should read as a picture of its output, and the generator already uses Mustache | No expressions at all, so every derived value needs a view file, and the engine escapes HTML by default | Any name has to be computed, sorted or checked |
| A function over the model | A dispatch table, an overload matrix, an import list: anything looped, sorted, de-duplicated or validated | The shape of the output is invisible until you run it, and a change to it is a diff in code, not in output | The section is the same for every API |
| sdkgen TypeScript components | The generator is sdkgen and the section depends on your API, so it belongs in .sdk/src/cmp | A component API to learn, and a section that runs on every generate beside the stock components | The generator is not sdkgen |
| ts-morph post-generation transform | You do not own the template, and the section has to be added to a file something else wrote | The TypeScript compiler loaded per run, and a transform that re-parses output somebody else may change | You own the generator and can emit the section directly |
A template is a picture of its output, and that is the whole reason to keep one: a reviewer reads the template and knows what the file will look like. Mustache keeps that property by allowing no logic at all, so every computed name moves into a view file. Handlebars keeps it by allowing helpers, so the names move into helpers. A function keeps none of it and can compute anything.
Compute what a template cannot
The section needs four things no template computes on its own. Two are names: a PascalCase type
name from an entity name, and a result type that is an array for list and a record otherwise.
Two are checks: an import list that is sorted and de-duplicated, and a test that every derived
name is an identifier.
export function derive(model: Model): Row[] {
return model.entities.flatMap((e) =>
e.ops.map((op) => {
const entity = pascal(e.name)
return {
key: `${e.name}.${op.name}`,
method: op.method,
path: op.path,
args: `${entity}${pascal(op.name)}Args`,
result: op.name === 'list' ? `${entity}[]` : entity,
}
}),
)
}
// The import list: every type the section mentions, once, sorted.
export const typeNames = (rows: Row[]) =>
[...new Set(rows.flatMap((r) => [r.args, r.result.replace('[]', '')]))].sort()
// Refuse a name that cannot be a TypeScript identifier before anything is
// written. A template has no place to put this check.
export function checkIdentifiers(rows: Row[]): void {
for (const r of rows) {
for (const name of [r.args, r.result.replace('[]', '')]) {
if (!IDENT.test(name)) throw new Error(`${r.key}: "${name}" is not an identifier`)
}
}
}
The component itself is then a list of lines.
export function renderDispatch(model: Model): string {
const rows = derive(model)
checkIdentifiers(rows)
const lines = [
'// Generated from the model. Do not edit.',
'',
`import type { ${typeNames(rows).join(', ')} } from "./types";`,
'',
`export type OpName = ${rows.map((r) => `"${r.key}"`).join(' | ')};`,
'',
'export const OPS = {',
...rows.map((r) => ` "${r.key}": {method: "${r.method}", path: ${JSON.stringify(r.path)}},`),
'} as const;',
'',
'export interface Dispatch {',
...rows.map((r) => ` call(op: "${r.key}", args: ${r.args}): Promise<${r.result}>;`),
'}',
]
return lines.join('\n') + '\n'
}
checkIdentifiers runs before the first line is built. That ordering is the point: a component
can refuse, and a template can only write. The path is not a name, so it is not checked; it is a
string literal in the output, and JSON.stringify escapes whatever it carries. The overload matrix is the case the
TypeScript handbook allows
overloads for, because each operation returns a different type and a union of the arguments
would return a union of the results.1
Read the component again and try to picture the file it writes. You cannot, not exactly. The import line is one line, the union is one line, and the object entries have no spaces inside the braces. The output is correct TypeScript and it is not formatted, which is the pitfall the rest of this page is about.
Hold the output to a fixture, and format it first
Commit the formatted output as a fixture and assert against it. The fixture is the picture of the output that the component took away.
export const format = (source: string) => prettier.format(source, { parser: 'typescript' })
test('the fixture is what prettier would print, so a stale fixture cannot pass', async () => {
assert.equal(await format(fixture), fixture)
})
test('the component output equals the fixture once formatted', async () => {
assert.equal(await format(renderDispatch(model)), fixture)
})
The format call from Prettier runs on the component’s output,
never on the fixture at test time. The first test exists because a fixture somebody hand-edits into an
unformatted state passes the second test only while the component drifts to match it. The two
together pin the shape and the formatting, and a review of a change to the component is a review
of the diff in the fixture.
The formatter earns a second job here. It is a parser, so it throws on output that is not TypeScript, and that catches a broken name a template wrote before the file reaches anyone.
node --test component.test.ts
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 844.980761
The fifth test renders the section all five ways and asserts each one formats to the fixture, which is what makes the comparison below a test rather than an opinion.
Check it worked
Render the same section five ways. Compare each against the fixture as written, and again after
formatting. Then add an operation named usage.summary, whose derived type name cannot be an
identifier, and watch where each approach fails.
node compare.ts
model: 2 entities, 6 operations, 8 type names
approach raw = fixture formatted = fixture
mustache template yes yes
handlebars template yes yes
component no yes
sdkgen-style component no yes
ts-morph transform no yes
with an operation named usage.summary
mustache template wrote it; prettier refused the file: ',' expected. (9:13)
handlebars template wrote it; prettier refused the file: ',' expected. (9:13)
component refused before writing: meter.usage.summary: "MeterUsage.summaryArgs" is not an identifier
sdkgen-style component refused before writing: meter.usage.summary: "MeterUsage.summaryArgs" is not an identifier
ts-morph transform wrote it; prettier refused the file: ',' expected. (9:15)
The first table is the template’s argument. Both templates produce the fixture byte for byte with no formatter, because the template was written to look like the output. The three code approaches produce correct, unformatted text and reach the fixture only through Prettier.
The second table is the component’s argument. The two components refuse the bad operation with
its name in the message, before a file exists. The templates and the transform write a file that
is not TypeScript, and the first thing to object is the formatter, at line 9. Without a formatter
in the pipeline the objection comes from tsc in the user’s build.
One thing the table cannot show: on this machine the ts-morph run took ten to a hundred times longer than any other, most of it loading the TypeScript compiler. That figure is from a local run and is not checked by the tests.
Read the template as a picture
The Mustache template is worth reading beside the component, because it shows what the component cost you.
export type OpName =
{{#ops}}
| "{{key}}"{{#last}};{{/last}}
{{/ops}}
export const OPS = {
{{#ops}}
"{{key}}": { method: "{{method}}", path: "{{{path}}}" },
{{/ops}}
} as const;
export interface Dispatch {
{{#ops}}
call(op: "{{key}}", args: {{args}}): Promise<{{{result}}}>;
{{/ops}}
}
Every line is a line of the output with holes in it. A reviewer who has never seen Mustache can say what this file writes, and a change to the output is a change to the line that produces it. That is why OpenAPI Generator ships its templates in Mustache: the people overriding a template are the people reading the output.
The price is the view. Mustache has no @last, no helpers and no expressions,2 so key,
args, result and last are all computed in a second file.
export function view(model: Model) {
const rows = derive(model)
const withLast = <T>(list: T[]) =>
list.map((item, i) => ({ ...item, last: i === list.length - 1 }))
return {
ops: withLast(rows),
types: withLast(typeNames(rows).map((name) => ({ name }))),
}
}
The logic did not go away. It moved into code the template reader does not see, and the view could refuse a bad name as well as the component can, since it is code. What the template buys is the split: the shape in one file, the rules in another.
Handlebars narrows the gap with
@last and depth paths inside nested
loops and with registered helpers, so the
template here reads the raw model and needs three helpers rather than a view. The helpers are
still code, in a third file, and {{#if @../last}} is the kind of expression that needs a
comment.
Write it as an sdkgen component
sdkgen draws the line with one question, in its own words: same for every API, template;
depends on your API, component. Its components are TypeScript files in .sdk/src/cmp/<target>/,
written on jostraca, and the stock ones write the entity classes, the
tests and the README files. This page’s component is the same shape, run with jostraca directly so it
needs no generated project.
export const Dispatch = cmp(function Dispatch(props: any) {
const rows = derive(props.ctx$.model)
checkIdentifiers(rows)
File({ name: 'dispatch.ts' }, () => {
Line('// Generated from the model. Do not edit.')
Line('')
Line(`import type { ${typeNames(rows).join(', ')} } from "./types";`)
Line('')
Line(`export type OpName = ${rows.map((r) => `"${r.key}"`).join(' | ')};`)
Line('')
Line('export const OPS = {')
for (const r of rows) Line(` "${r.key}": {method: "${r.method}", path: ${JSON.stringify(r.path)}},`)
Line('} as const;')
Line('')
Line('export interface Dispatch {')
for (const r of rows) Line(` call(op: "${r.key}", args: ${r.args}): Promise<${r.result}>;`)
Line('}')
})
})
A cmp reads the model from props.ctx$ and describes the file with File and Line calls.
Nothing is written while it runs; jostraca builds the tree afterwards, which is what lets a second
run over hand-edited output diff or merge instead of overwrite. Inside sdkgen the same component
sits beside Entity_ts.ts and Main_ts.ts and runs on every generate, and the drift check
reports it as additive rather than as a fork.
The component has the same blind spot as the plain function: its raw output is not the fixture. Run the formatter over generated output as a build step in the target, or assert against a fixture in the component’s own tests, or both.
When it goes wrong
The output is correct and the diff is unreadable. The component emits the import list on one line and the union on one line, and a reviewer sees a 140 character line change when one type was added.
node pitfall.ts
component output against the fixture: first difference at line 3
component: import type { Meter, MeterCreateArgs, MeterListArgs, MeterLoadArgs...
fixture: import type {
after prettier: identical
mustache {{path}} on /meters/{id}: /meters/{id}
mustache {{{path}}} on /meters/{id}: /meters/{id}
Format before you compare, and commit the formatted file. The unformatted text is an intermediate that nobody should read.
The second failure is a path that arrives as /meters/{id}. Mustache escapes for HTML
by default and its table includes the slash.3 Use the triple mustache, {{{path}}}, for every
value that is code, or replace the escape function for the whole render.
The third is a transform that formats to a different standard. ts-morph’s
formatText indents with four spaces unless told
otherwise, so a section it adds does not match the file around it. Run one formatter over the
whole file after the transform, not the transform’s own.
When not to do this
Do not write a component for a section that is the same for every API. The transport, the base
classes and the error type do not loop over anything, and a template for them is a file a reviewer
can read. sdkgen ships those parts as templates under .sdk/tm/ for that reason, and reserves
components for what depends on the model.
Do not adopt sdkgen to get its component system for one section of a generator you already run. A component is a function over a model, and the function on this page has no dependency at all. The component system pays off when the whole generator is model-driven and the section has to run beside the stock components on every generate.
Do not use a post-generation transform on a file you could have generated correctly. ts-morph is the answer when something else owns the template. It loads a compiler to edit text you could have emitted, and it re-parses output that the upstream template may change under you.
Do not skip the fixture because the tests pass. A component with no fixture has no reviewable output, so a change to the shape of what it writes is reviewed as a change to string concatenation.
Related how-tos
Last verified
Verified 2026-09-24 against Node 22.22.2, mustache 4.2.0, handlebars 4.7.9, ts-morph 28.0.0, prettier 3.9.9 and jostraca 0.39.0. Every output block is what the command preceding it printed. The sdkgen row runs on jostraca, the library sdkgen’s components are written on; @voxgig/sdkgen 4.24.3 was read from the published package, not run. The timing figures are from a local run.
Footnotes
-
The handbook’s advice on the same page is to prefer a parameter with a union type over overloads whenever possible. A generated dispatch table is the case where it is not possible. Six operations returning four types is a matrix, and a matrix written as a union returns the union. The handbook is right in general and this is the exception, which is where generated code usually lives. ↩︎ Back to text
-
The Mustache language is documented as a Unix manual page, mustache(5). Section 5 of the manual is for file formats and configuration files, according to man-pages(7), which files a template language beside
fstabandpasswd. The page says the templates are called logic-less because there are no if statements, else clauses or for loops. It then spends most of its length on sections, which render a block zero or more times depending on a value. A loop by another name is still the thing the page says is absent, and the page has not needed a second edition to say so. ↩︎ Back to text -
The escape table in mustache.js has eight entries. Five are the ampersand, the two angle brackets and both quotes. The other three are a slash, a backtick and an equals sign, which are not HTML syntax and are escaped against attribute contexts. Point it at a path and every separator comes back as
/. The remedy is a third brace, which the manual page calls the triple mustache, so the cure for an HTML habit is more punctuation. ↩︎ Back to text