How-to › Model, configure and generate

How to format generated code as the last step of generation#

Format generated TypeScript, Go, and Python in memory with the consuming project's settings, before the generator writes, so no run needs a formatting commit.

Audience
Platform team
Level
beginner
Topic
Engineer templates and AST transforms
Languages
TypeScript, Go and Python
Verified

Your generator writes a TypeScript client that compiles, but the first developer to save it in an editor gets a diff on every line. The editor formats with the project’s .prettierrc.json and .editorconfig, while the generator called Prettier with its defaults. CI runs prettier --check and fails on a file nobody edited, so someone commits a formatting fix after every regeneration.

What you get

You will end up with generators in TypeScript, Go, and Python that format their output in memory, with the settings the consuming project’s own tools read, before writing. This is for you if you own a generator whose output needs a formatting commit.

Short answer

Format generated code with Prettier, go/format, or Ruff inside the generator, after rendering and before writing, using the settings the project’s own tools read, so a regeneration never needs a formatting commit. With Prettier, pass resolveConfig(outPath, { editorconfig: true }) and filepath: outPath to format. In Go, run format.Source on the rendered bytes. In Python, pipe the source through ruff format --stdin-filename. Then run the check mode of each formatter and expect zero changes.

const config = await prettier.resolveConfig(outPath, { editorconfig: true })
const text = await prettier.format(source, { ...config, filepath: outPath })
await writeFile(outPath, text)

You will need

A generator that writes files to disk, and a formatter for each language it emits. Here those are Prettier from npm for TypeScript, the Go toolchain for go/format, and Ruff from PyPI for Python. Verified 2026-10-21 against Node 22.22.0, Prettier 3.9.10 and 2.8.8, Go 1.24.7, Python 3.13.16, and Ruff 0.17.1. The sample renders one API model into a client in each language, formats it, and checks it. It does not run Biome or a pre-commit hook; those two rows come from their documentation.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
BiomeThe consuming project already formats JavaScript or TypeScript with BiomeA second formatter to pin, with defaults that differ from those of Prettier, starting with tab indentationThe project formats with Prettier, whose settings files Biome does not read at format time
gofmt and go/formatThe generator is written in Go and emits GoOutput follows the Go release that compiled the generator, which the package’s own overview calls fragile for pre-submit checksA pre-submit check needs one fixed style, where a pinned gofmt binary is steadier
pre-commit in the consuming repositoryMany repositories consume the output, and each already pins its own formatter hooksThe diff you ship depends on hook versions you never see, and unformatted output reaches every reviewer firstPeople read the generated files in review before any hook runs
PrettierThe generator runs in Node and emits TypeScript, JavaScript, JSON, or MarkdownOne more dependency to pin, and its output moves when a major version changes a defaultThe project formats with Biome, or the generator does not run in Node
RuffThe generator emits Python for a project that follows Black’s styleA child process per file, because the package installs a binary and exports no formatting functionThe project pins Black and its own configuration for it

Formatting inside the generator pins the formatter with the generator, so every consumer receives the same bytes, at the cost of one more dependency to keep current. Leaving it to pre-commit or CI in the consuming repository respects that repository’s settings, but the diff you ship then depends on formatter versions you never see. Biome reads biome.json rather than .prettierrc, and its migration guide writes every option out explicitly because its defaults differ from those of Prettier.

Format the string before it reaches the disk

Put the formatter between the template and writeFile. The template renders content with no attention to layout, and the formatter turns that string into the bytes the project’s own tools would produce. The file is written once, already formatted, so no unformatted copy ever sits on disk for a watcher, a test, or a commit to pick up.

// Resolve the configuration the Prettier CLI and an editor would find for this path, then
// format with that path as filepath. editorconfig: true is the CLI's default and not the API's.
export async function formatFor(path, source) {
  const config = await prettier.resolveConfig(path, { editorconfig: true })
  return prettier.format(source, { ...config, filepath: path })
}

// Format in memory and write once. A template that renders invalid TypeScript makes format
// throw, so a broken generator fails here and leaves no half-formatted file behind.
export async function generate(model, outPath) {
  const formatted = await formatFor(outPath, render(model))
  await mkdir(dirname(outPath), { recursive: true })
  await writeFile(outPath, formatted)
  return formatted
}

resolveConfig searches upward from the output path, as the CLI does, so it finds the consuming project’s .prettierrc.json. The CLI also reads .editorconfig unless told not to, while the API reads it only when asked.1 filepath lets Prettier infer the TypeScript parser from the extension.

node generate.mjs && npx prettier --check generated/client.ts
wrote generated/client.ts: 32 lines
prettier 3.9.10, config .prettierrc.json and .editorconfig
Checking formatting...
All matched files use Prettier code style!

The check exits 0 straight after generation, so the file needs no second pass. Its four-space indentation comes from .editorconfig, and its single quotes and missing semicolons come from .prettierrc.json.

Format Go in process with go/format

format.Source parses the rendered bytes and prints them in canonical gofmt style, with the imports sorted. gofmt takes flags for listing, diffing, rewriting, and simplifying but no style options, so there is no configuration to resolve.

// Generate renders the model and formats the result in memory. A template that renders invalid
// Go makes format.Source return an error, and nothing is written.
func Generate(m Model) ([]byte, error) {
	var buf bytes.Buffer
	if err := client.Execute(&buf, m); err != nil {
		return nil, err
	}
	formatted, err := format.Source(buf.Bytes())
	if err != nil {
		return nil, fmt.Errorf("generated Go does not parse: %w", err)
	}
	return formatted, nil
}

The template behind it writes the import list on one line with semicolons, and each function body on a single line. Both are legal Go, and format.Source lays them out the way gofmt would.

go run . && test -z "$(gofmt -l generated)" && echo "gofmt -l lists no files"
wrote generated/client.go: 70 lines
gofmt -l lists no files

gofmt -l prints the name of every file whose formatting differs, and test -z turns an empty list into success. The output carries the formatting of the Go release that compiled the generator, which the package documents as a reason to prefer a pinned binary for checks.2

Pipe the Python output through ruff format

The ruff package on PyPI installs a binary and exports no formatting function, so the Python generator runs it as a child process on standard input. --stdin-filename names the output path, and Ruff starts its search for configuration from there.

def format_for(path, source):
    # The ruff package ships a binary and exports no formatting function, so the pass is a
    # subprocess on stdin. --stdin-filename makes Ruff find ruff.toml from the output path.
    result = subprocess.run(
        [sys.executable, '-m', 'ruff', 'format', '--stdin-filename', str(path), '-'],
        input=source,
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        raise RuntimeError(f'ruff could not format {path}: {result.stderr.strip()}')
    return result.stdout
PYTHONPATH=pydeps python3 generate.py && PYTHONPATH=pydeps python3 -m ruff format --check generated
wrote generated/client.py: 31 lines
1 file already formatted

Ruff’s formatter leaves import json, os on one line, because splitting it is lint rule E401, fixed by ruff check --fix rather than by formatting, so the template writes one import per line itself. Black checks the syntax tree after formatting and leaves such a line alone too, and Ruff measures how closely the two agree.3

Check it worked

Run the suite. It regenerates each client, runs the check mode of each formatter over the result, and feeds all three generators a model that renders invalid code.

test('the Go client is gofmt clean, and invalid Go writes nothing', () => {
  execFileSync('go', ['run', '.'], { encoding: 'utf8' })
  assert.equal(execFileSync('gofmt', ['-l', 'generated'], { encoding: 'utf8' }), '')
  const outPath = join(brokenDir, 'client.go')
  const run = spawnSync('go', ['run', '.', '-model', brokenModel, '-out', outPath], {
    encoding: 'utf8',
  })
  assert.equal(run.status, 1)
  assert.match(run.stderr, /generated Go does not parse/)
  assert.equal(existsSync(outPath), false)
})
node --test generate.test.mjs
1..8
# tests 8
# suites 0
# pass 8
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 856.540218

# pass 8 and # fail 0 mean all three tools accept their output unchanged. A model whose operation name contains a space makes every generator exit with an error and write no file. A broken template therefore cannot leave half a client behind for someone to commit.

When it goes wrong

Saving a generated file in the editor changes its layout. The generator called Prettier without the output path, or without editorconfig: true, so it formatted with settings the CLI never uses. This script formats one string three ways and asks the CLI about each result.

node pitfall.mjs && npx prettier --check pitfall
format(source) with no parser and no filepath: No parser and no file path given, couldn't infer a parser.
pitfall/parser-only.ts          indent 2, quotes ", semicolons yes
pitfall/no-editorconfig.ts      indent 2, quotes ', semicolons no
pitfall/as-the-cli-reads-it.ts  indent 4, quotes ', semicolons no
Checking formatting...
[warn] pitfall/no-editorconfig.ts
[warn] pitfall/parser-only.ts
[warn] Code style issues found in 2 files. Run Prettier with --write to fix.

Prettier throws when it has neither a parser nor a path. The other two calls succeed and produce valid TypeScript, but the CLI rejects both files: one read no configuration, and one skipped .editorconfig. Ruff has the same trap. Without --stdin-filename it looks for configuration from the working directory, so a generator started elsewhere formats with defaults, as the seventh test shows.

A consuming repository’s CI rejects the file after either side upgrades its formatter. Formatter output changes between major versions, so a file formatted under one major can fail the other’s check. Here is the same string and configuration through two major versions of Prettier.

node drift.mjs
prettier 2.8.8 and 3.9.10, generated/client.ts
line 5
  2.8.8  |        private readonly fetchImpl: typeof fetch = fetch
  3.9.10 |        private readonly fetchImpl: typeof fetch = fetch,
line 19
  2.8.8  |        params: Record<string, string | number>
  3.9.10 |        params: Record<string, string | number>,

Both lines are trailing commas, because the default for trailingComma changed between those majors.4 Set every option the output depends on explicitly in the configuration file, trailingComma included, so a change of default cannot move it, and pin the formatter in the generator’s lockfile. Explicit settings close the gap that defaults leave, but not the layout rules a major version changes on its own.

When not to do this

Do not format inside the generator when its output lands in many repositories that each format on commit with their own settings. Every consumer formats the file again anyway, so the pass in the generator adds a dependency without changing what they see. Ship output that parses, and leave layout to their hooks.

Do not replace the in-memory pass with prettier --write over the output directory. A directory pass also rewrites files the generator did not produce, such as a hand-written module beside the generated ones, and the unformatted bytes sit on disk until it runs.

Do not expect a formatter to fix nondeterminism. It normalizes layout, so map order, timestamps, and absolute paths survive formatting and still differ between two runs.

Do not format output that no person reads, such as a minified bundle or a wire-format fixture. The pass costs time on every run, which pays off only when a person reads the output.

Last verified

Verified 2026-10-21 against Node 22.22.0, Prettier 3.9.10 and 2.8.8, Go 1.24.7, Python 3.13.16, and Ruff 0.17.1. Every output block is what the command preceding it printed. Biome, Black, and pre-commit were read from their documentation, not run, and the consuming project is modeled by the sample directory’s own .prettierrc.json, .editorconfig, and ruff.toml.

Footnotes

  1. Prettier documents the setting twice, from opposite ends. The CLI reference lists --no-editorconfig, a flag for switching the file off, which makes reading it the default. The API reference describes options.editorconfig as something that applies when it is true. Whether .editorconfig counts therefore depends on which entrance a caller used. ↩︎ Back to text

  2. The standard library’s formatting package recommends something other than itself. Its overview says that formatting of Go source changes over time, so tools relying on consistent formatting should run a specific version of the gofmt binary. Its example is a pre-submit check that calls the package directly and so behaves differently with each developer’s Go version. A generator built on the package carries the formatting of whichever Go release compiled it. ↩︎ Back to text

  3. Ruff’s formatter documentation describes it as a drop-in replacement for Black and reports that more than 99.9% of lines in Django and Zulip come out identical. Black’s own FAQ, asked whether Black has an API, answers “Not yet” and says a simple one is being planned. The same page gives the style a calendar: output is stable across the releases of one year, and the first release of each year changes it. ↩︎ Back to text

  4. The trailing comma has had three defaults in three major versions. Before 2.0, Prettier avoided trailing commas where it could, which kept output compatible with environments as old as IE8. The 2.0 release notes changed the default to es5, after an initiative that had been open for over three years, and added that the default might become all in a future major version. The 3.0 release notes made that change and gave the reason: Internet Explorer, the last browser to reject trailing commas in function calls, lost support on June 15, 2022. The two trailing commas that drift.mjs reports are therefore a decision about Internet Explorer. ↩︎ 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.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.