# How to decide between generating and hand-writing a client

> Count the operations, schemas and parameters you would maintain, then decide between a generated client and one you write on the numbers rather than on taste.

Source: https://voxgig.com/howto/generate-or-hand-write-a-small-api-client

- Audience: api-consumer
- Level: beginner
- Languages: typescript, javascript
- Verified: 2026-09-14
- Published: 2026-09-14
- Updated: 2026-09-24

## Short answer

Count the operations and the schemas in the description. Under about eight operations and ten schemas, a hand-written client fits on one screen and reads better than anything generated. Past about twenty-five operations, nobody keeps a hand-written client current with the API. Between the two, measure again after the next two releases and let the trend decide.

---
## You will need

An OpenAPI 3 description of the API you call, and Node 22 or later. If the vendor publishes no
description, that is itself a finding: generating needs one, so the choice is already made for you.
The counting uses the [Paths Object](https://spec.openapis.org/oas/v3.1.0.html#paths-object) and the
[component schemas](https://spec.openapis.org/oas/v3.1.0.html#components-object), both of which any
description carries.[^2]

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A coding agent writing the client once](https://www.anthropic.com/engineering/claude-code-best-practices) | A small surface, where you want the shape of a hand-written client without typing it | Output you own and must review, with no regeneration path when the API moves | The API changes often enough that regeneration is the point |
| [A hand-written client](https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch) | A handful of operations, where the file is short enough to read in one sitting | Every change tracked by a person, and no types derived from the description | The surface grows past what one file should hold |
| [OpenAPI Generator](https://openapi-generator.tech/docs/usage) | Many languages, or a large surface that has to stay in step with a description | A Java toolchain, generated code in your repository, and a template layer to learn | You need one language and a small client |
| [openapi-typescript with openapi-fetch](https://openapi-ts.dev/) | TypeScript consumers who want types from the description and a tiny runtime | Types only, so behavior such as retry and pagination is still yours to write | You want operations as methods rather than typed paths |

The axis that matters is how often the description changes. A client you write by hand is the
clearest code you will have, right up to the release where the vendor adds fourteen endpoints and
renames a field. Generation converts that release into a command. What it costs is a layer between
your code and the wire, and the day you need to work around a vendor bug, that layer is where you
will be.

## Count the job before choosing

The job comes down to four numbers, each counted rather than argued over.

```js title="measure.mjs"
export function advice(m) {
  if (m.operations > 25 || m.schemas > 20) return 'generate: the surface is past what a person will keep current'
  if (m.operations <= 8 && m.schemas <= 10) return 'hand-write: the whole client fits on one screen'
  return 'either: measure again after the next two releases'
}
```

The thresholds are starting points, and the value is in recording them. A team with a written rule
argues once, about the rule, and every later client is a measurement. A team without one argues
every time, and the outcome tracks whoever cares most that week.

Run the measurement again at each vendor release. An API that grows from six operations to nineteen
in a year has told you something no first decision could. Keep the numbers in the repository, beside
the client, so the next person inherits the reasoning rather than the verdict.

## Write the small one properly

A hand-written client earns its place by being complete rather than by being short.

```js title="client.mjs"
if (!res.ok) {
  const problem = await res.json().catch(() => ({}))
  throw Object.assign(new Error(problem.title ?? `HTTP ${res.status}`), { status: res.status, problem })
}
return res.status === 204 ? null : res.json()
```

One place builds the URL, one place reads an error, and one place decides what an empty body means.
Three decisions, each made once. That is the part a generator gets right by default, and the part a
hand-written client gets wrong when each operation is written separately.

The client takes its `fetch` as an argument. That one line is what lets the argument-building tests
run with no network at all. It is also where a retry or a timeout wrapper goes later, without
touching a single operation.

## Check it worked

Measure the description and the client together.

```bash
node measure.mjs
```

```text output
operations   4
parameters   4
bodies       2
schemas      3
clientLines  27
advice       hand-write: the whole client fits on one screen
```

Twenty-seven lines of code against four operations, and a generated package would be two orders of
magnitude larger for the same four calls. That ratio is the argument, and it reverses completely at
a hundred operations.

The `parameters` and `bodies` counts are the ones people forget. Four operations with twelve
parameters between them is a different job from four with none. Every parameter is a name, a type,
and a position in the URL, and each of the three is somewhere a person can be wrong.

```bash
node --test client.test.mjs
```

```text output
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 327.740598
```

## When it goes wrong

The hand-written client grows a copy of the same header logic per method. Somebody added an
operation by copying one. Keep the single `call` function, and let every operation go through it.
Four lines per operation is the target, and anything longer is logic that belongs in the shared
path.

A query parameter arrives at the API as the string `undefined`. The URL builder did not skip absent
values. Test that case, because it fails silently and returns a plausible response.

The generated client will not compile after a vendor release. A schema changed in a way the
generator maps differently. Pin the description, regenerate deliberately, and read the diff. A
regeneration triggered by a dependency update is the version of this that wakes somebody at night.

Types drift from the runtime behavior. The description is wrong rather than the generator. Validate
a real response against the description once, and report what you find to the vendor. A description
nobody validates against the service is a document rather than a contract.

## When not to do this

Do not hand-write a client for an API whose description you already trust and whose surface is
large. The typing is not the cost. Keeping four hundred fields current by hand is the cost, and it
falls on whoever is on call.

Do not generate a client to get types you could get from the description alone. A types-only path
costs far less than a generated runtime, and it leaves you writing the code that makes the calls.[^3]

Do not treat the choice as permanent. Both directions are a day of work at this size, and the
measurement is the thing worth keeping. Write down which way you went and why, in three lines, and
the next argument starts from evidence.

## Related how-tos

- [Add a bearer token to fetch without a client library](/howto/bearer-token-fetch-wrapper)

- [Retry fetch calls with exponential backoff in Node.js](/howto/retry-fetch-with-backoff-in-node)

## Last verified

Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.

[^1]: The generator has requirements of its own before the package does. OpenAPI Generator's
[installation page](https://openapi-generator.tech/docs/installation/) asks for a Java 11 runtime at
a minimum, and offers a PyPI package that still needs a Java executable to run. It also offers a
Docker image for developers who are unable to install Java or upgrade the installed version. Its
[`list` command](https://openapi-generator.tech/docs/usage/) prints every available generator and
excludes the deprecated ones by default, so the catalog a newcomer reads is shorter than the one
that exists.

[^2]: The requirement is looser than it looks. [OpenAPI
3.1.0](https://spec.openapis.org/oas/v3.1.0.html#openapi-document) says a document MUST contain at
least one of a `paths` field, a `components` field, or a `webhooks` field. A description made only
of webhooks is therefore valid and has no operations to count. The section on the [Paths
Object](https://spec.openapis.org/oas/v3.1.0.html#paths-object) rules that `/pets/{petId}` and
`/pets/{name}` are identical and invalid, so an operation count is also a claim that nobody has
written one path twice under different names.

[^3]: The front page of [openapi-typescript](https://openapi-ts.dev/) promises zero runtime cost and
zero client weight, on the grounds that nothing is faster than instant. It says the generated types
check an entire codebase with no setup and no tests. The types are the whole product. Every fetch
call, retry, and page walk is still yours, which is what the table's cost column says in fewer
words.