AuthonAuthon Blog
debugging6 min read

Why your generated SDK is full of 'any' types — and how to fix it

Your auto-generated TypeScript SDK is full of `any` types? The fix isn't the generator — it's your OpenAPI spec. Here's how to tighten it up.

AW
Alan West
Authon Team
Why your generated SDK is full of 'any' types — and how to fix it

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:

yaml
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:

yaml
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:

yaml
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:

yaml
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:

typescript
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:

yaml
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.

bash
# 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
fi

I 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.

Why your generated SDK is full of 'any' types — and how to fix it | Authon Blog