How-to › Ship an SDK

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.

Audience
Library maintainer
Level
advanced
Topic
Customize a generator without forking
Languages
TypeScript
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
Handlebars templatesThe section is mostly literal text with a loop or two, and you want helpers for the namesHelpers are code in another file, and @../last is the kind of path a reviewer has to look upThe section computes more than it copies
Mustache templatesThe template should read as a picture of its output, and the generator already uses MustacheNo expressions at all, so every derived value needs a view file, and the engine escapes HTML by defaultAny name has to be computed, sorted or checked
A function over the modelA dispatch table, an overload matrix, an import list: anything looped, sorted, de-duplicated or validatedThe shape of the output is invisible until you run it, and a change to it is a diff in code, not in outputThe section is the same for every API
sdkgen TypeScript componentsThe generator is sdkgen and the section depends on your API, so it belongs in .sdk/src/cmpA component API to learn, and a section that runs on every generate beside the stock componentsThe generator is not sdkgen
ts-morph post-generation transformYou do not own the template, and the section has to be added to a file something else wroteThe TypeScript compiler loaded per run, and a transform that re-parses output somebody else may changeYou 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}:   &#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 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.

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

  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 is right in general and this is the exception, which is where generated code usually lives. ↩︎ Back to text

  2. 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 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. ↩︎ Back to text

  3. 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 &#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. ↩︎ 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.