How-to › Ship an SDK

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.

Audience
API consumer
Level
beginner
Topic
Choose a generator and generate an SDK
Languages
TypeScript and JavaScript
Verified

You need four calls against a vendor API. The team argues for a generator, and the generated package arrives with 80 files, a runtime dependency, and types for endpoints nobody calls.1 The argument against it is a feeling that it is too much, and a feeling loses to a tool with a homepage.

What you get

You will end up with a number for the size of the job, and a rule that turns that number into a decision. You also get a hand-written client worth comparing against. This is for you if you are about to add a client for an API you consume.

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 and the component schemas, both of which any description carries.2

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
A coding agent writing the client onceA small surface, where you want the shape of a hand-written client without typing itOutput you own and must review, with no regeneration path when the API movesThe API changes often enough that regeneration is the point
A hand-written clientA handful of operations, where the file is short enough to read in one sittingEvery change tracked by a person, and no types derived from the descriptionThe surface grows past what one file should hold
OpenAPI GeneratorMany languages, or a large surface that has to stay in step with a descriptionA Java toolchain, generated code in your repository, and a template layer to learnYou need one language and a small client
openapi-typescript with openapi-fetchTypeScript consumers who want types from the description and a tiny runtimeTypes only, so behavior such as retry and pagination is still yours to writeYou 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.

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.

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.

node measure.mjs
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.

node --test client.test.mjs
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.

Last verified

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

Footnotes

  1. The generator has requirements of its own before the package does. OpenAPI Generator’s installation page 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 prints every available generator and excludes the deprecated ones by default, so the catalog a newcomer reads is shorter than the one that exists. ↩︎ Back to text

  2. The requirement is looser than it looks. OpenAPI 3.1.0 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 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. ↩︎ Back to text

  3. The front page of openapi-typescript 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. ↩︎ 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.