Last month I shipped a new internal API at work, generated the TypeScript SDK from our OpenAPI 3.1 spec, and opened up the resulting client file. Half the response types were any. Most of the request bodies were Record. Errors? Untyped. The whole thing was technically a "typed SDK" but in practice, it gave me about as much safety as fetch() with my eyes closed.
If you've been there, you already know the feeling. Generated SDKs are supposed to do the boring work for you. When they don't, it's almost always your spec, not the generator.
Here's the walkthrough I wish I had when I started fixing ours.
The symptom
You run your codegen tool. The output compiles. You import the client, hit autocomplete, and... nothing useful. response.data is any. error is unknown. The WidgetCreatePayload you expected? It's an inline object with three properties marked unknown.
Most teams shrug and start hand-typing wrappers around the "typed" client. Don't do that. You're treating the symptom.
Root cause: anonymous, half-described schemas
OpenAPI generators emit good types when your spec gives them three things:
- Named schemas (so they have something to call the type)
- Concrete property types (not bare objects)
- Discriminators for union types
Anonymous inline objects break the first one. Loose type: object declarations with no properties break the second. oneOf without a discriminator breaks the third. Each of these gets degraded to any, unknown, or Record depending on your generator's settings.
Most specs I've audited fail on all three at once.
Fix 1: name your schemas
Compare these two specs. First, the version that generates garbage:
paths:
/widgets:
post:
requestBody:
content:
application/json:
schema:
type: object
properties:
name:
type: string
config:
type: object # the trap — bare object, no shape
responses:
'200':
content:
application/json:
schema:
type: object
properties:
id: { type: string }Two problems. The inline requestBody has no name, so the generator emits a synthetic type like CreateWidgetBody. Fine, but the config field is a bare object — that becomes Record or any. The response is also inline and unnamed, so it can't be reused anywhere else in the client.
Move everything to components/schemas and reference it:
components:
schemas:
WidgetConfig:
type: object
required: [region, tier]
properties:
region: { type: string, enum: [us-east, us-west, eu] }
tier: { type: string, enum: [free, pro, enterprise] }
CreateWidgetRequest:
type: object
required: [name, config]
properties:
name: { type: string, minLength: 1 }
config: { $ref: '#/components/schemas/WidgetConfig' }
Widget:
type: object
required: [id, name, config]
properties:
id: { type: string, format: uuid }
name: { type: string }
config: { $ref: '#/components/schemas/WidgetConfig' }
paths:
/widgets:
post:
requestBody:
content:
application/json:
schema: { $ref: '#/components/schemas/CreateWidgetRequest' }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/Widget' }Now you get real types: WidgetConfig, CreateWidgetRequest, Widget. The generator can reuse them across endpoints. When the shape changes, you change it in one place.
Fix 2: discriminate your unions
This one bites everyone. You have an endpoint that returns one of several event types:
EventResponse:
oneOf:
- $ref: '#/components/schemas/UserCreatedEvent'
- $ref: '#/components/schemas/UserDeletedEvent'
- $ref: '#/components/schemas/UserUpdatedEvent'Without a discriminator, most generators emit a useless union — TypeScript can't narrow it, so every field access needs a type guard you write by hand. Add the discriminator:
EventResponse:
oneOf:
- $ref: '#/components/schemas/UserCreatedEvent'
- $ref: '#/components/schemas/UserDeletedEvent'
- $ref: '#/components/schemas/UserUpdatedEvent'
discriminator:
propertyName: type
mapping:
user.created: '#/components/schemas/UserCreatedEvent'
user.deleted: '#/components/schemas/UserDeletedEvent'
user.updated: '#/components/schemas/UserUpdatedEvent'
UserCreatedEvent:
type: object
required: [type, user]
properties:
type: { type: string, enum: ['user.created'] } # single-value enum becomes a literal
user: { $ref: '#/components/schemas/User' }Now your client code narrows correctly:
const event = await client.events.next();
// TypeScript narrows on `type` — no manual guard needed
switch (event.type) {
case 'user.created':
console.log(event.user.email); // typed as User
break;
case 'user.deleted':
console.log(event.deletedAt); // typed as string
break;
}The single-value enum is the trick. It tells the generator to emit a string literal type, which is what makes the switch exhaustive.
Fix 3: actually describe your errors
Most specs document the happy path and call it a day. Then catch (err) hands you unknown and you're stuck typecasting.
Describe error responses with the same rigor as success ones:
components:
schemas:
ApiError:
type: object
required: [code, message]
properties:
code: { type: string }
message: { type: string }
details:
type: array
items: { $ref: '#/components/schemas/ApiErrorDetail' }
paths:
/widgets/{id}:
get:
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/Widget' }
'404':
content:
application/json:
schema: { $ref: '#/components/schemas/ApiError' }
'422':
content:
application/json:
schema: { $ref: '#/components/schemas/ApiError' }Different generators surface this differently — some give you a discriminated Result type, others throw typed exceptions. Either way, the spec needs the schema before anything downstream can use it.
Prevention: validate the spec before you generate
After fixing ours, I added two checks to CI to keep it from rotting.
The first is a structural validator. Several open-source OpenAPI linters will flag exactly the issues above — unnamed schemas, untyped objects, missing discriminators. I run mine on every PR that touches the spec. The OpenAPI Initiative tools page lists current options.
The second is a "no any" assertion on the generated output. After codegen, I grep the generated client for : any and Record outside a known allowlist. If the count goes up, the PR fails. Crude, but it works.
# Count any-typed properties in the generated client
ANY_COUNT=$(grep -c ': any' generated/client.ts || true)
# BASELINE is committed to the repo; bump it intentionally
if [ "$ANY_COUNT" -gt "$BASELINE" ]; then
echo "SDK quality regressed: $ANY_COUNT any types (baseline $BASELINE)"
exit 1
fiI keep BASELINE in a committed file. When you legitimately need to lower it, you update the file in the same PR. Reviewers see the change. Drift gets noticed.
The takeaway
A generated SDK is a mirror of your spec. If it's full of any, your spec is full of holes. The fix is rarely in the generator's settings — it's almost always going back to the source of truth and tightening it up.
Spend an afternoon naming your schemas, adding discriminators, and describing your errors. The SDK you get back will feel like one you wrote by hand, except you didn't have to.
