# How to generate a code section with code instead of a template

> Write a client's computed parts, a dispatch table and an overload matrix, as a function over the typed model, and hold its output to a formatted fixture.

Source: https://voxgig.com/howto/generate-a-code-section-with-a-component

- Audience: library-maintainer
- Level: advanced
- Languages: typescript
- Verified: 2026-09-24
- Published: 2026-09-24

## 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](https://nodejs.org/api/typescript.html),
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](https://handlebarsjs.com/guide/) | 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](https://mustache.github.io/mustache.5.html) | 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](https://www.typescriptlang.org/docs/handbook/2/objects.html) | 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](https://voxgig.com/sdk/custom) | 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](https://ts-morph.com/setup/) | 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.

```ts title="derive.ts"
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.

```ts title="component.ts"
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](https://www.typescriptlang.org/docs/handbook/2/functions.html) 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.

```ts title="format.ts"
export const format = (source: string) => prettier.format(source, { parser: 'typescript' })
```

```ts title="component.test.ts"
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](https://prettier.io/docs/api) 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.

```bash
node --test component.test.ts
```

```text output
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.

```bash
node compare.ts
```

```text output
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.

```handlebars title="dispatch.mustache"
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](https://openapi-generator.tech/docs/templating/) 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.

```ts title="view.ts"
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](https://handlebarsjs.com/guide/builtin-helpers.html) inside nested
loops and with [registered helpers](https://handlebarsjs.com/guide/expressions.html), 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](https://jostraca.org/), 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.

```ts title="Dispatch_ts.ts"
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.

```bash
node pitfall.ts
```

```text output
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}:   &#x2F;meters&#x2F;{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 `&#x2F;meters&#x2F;{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`](https://ts-morph.com/manipulation/formatting) 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

- [Fail a build when a generator customization becomes a fork](/howto/audit-generator-customisations-for-drift)

- [Preview a regeneration as a diff before writing files](/howto/preview-a-regeneration-as-a-diff)

- [Generate your first SDK with sdkgen](/howto/first-sdk-with-sdkgen)

## 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.

[^1]: 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](https://www.typescriptlang.org/docs/handbook/2/functions.html) is right in general
and this is the exception, which is where generated code usually lives.

[^2]: The Mustache language is documented as a Unix manual page,
[mustache(5)](https://mustache.github.io/mustache.5.html). Section 5 of the manual is for file
formats and configuration files, according to
[man-pages(7)](https://man7.org/linux/man-pages/man7/man-pages.7.html), which files a template
language beside `fstab` and `passwd`. 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.

[^3]: The escape table in [mustache.js](https://cdn.jsdelivr.net/npm/mustache@4.2.0/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 `&#x2F;`. The remedy is a third
brace, which the manual page calls the triple mustache, so the cure for an HTML habit is more
punctuation.