OpenAPI errors

Fix weak generated return types from OpenAPI's default response

A default response in responses documents the error shape for every status code you didn't declare explicitly — but a real generated TypeScript client drops it entirely. We reproduced this directly with openapi-generator's typescript-fetch template: a spec with 200 returning Message and default returning Error generates a method typed Promise<Message> with no trace of the error schema, while the Error model itself gets generated but is never imported or used anywhere in the client. Reproduced with real tools on 2026-09-21.

How do you reproduce the error?

Take a GET /messages/{id} operation with an explicit 200 and a default catch-all for everything else:

responses:
  "200":
    description: OK
    content:
      application/json:
        schema:
          $ref: '#/components/schemas/Message'
  default:
    description: Unexpected error
    content:
      application/json:
        schema:
          $ref: '#/components/schemas/Error'

redocly lint passes this spec (aside from unrelated style warnings) — default is a documented, valid key. Generating a client with openapi-generator-cli generate -g typescript-fetch produces:

async getMessageRaw(...): Promise<runtime.ApiResponse<Message>> {
    const response = await this.request(requestOptions, initOverrides);
    return new runtime.JSONApiResponse(response, (jsonValue) => MessageFromJSON(jsonValue));
}

async getMessage(...): Promise<Message> {
    const response = await this.getMessageRaw(requestParameters, initOverrides);
    return await response.value();
}

getMessage()'s return type is Promise<Message> — no union, no Error, nothing indicating a failure path exists at all. The Error schema does get generated as its own file, renamed ModelError.ts to avoid colliding with JavaScript's built-in Error:

$ ls out/models/
Message.ts
ModelError.ts
index.ts

But grep the rest of the generated client and ModelError is never imported or referenced anywhere outside its own file. It's dead code — generated because the spec defines the schema, unused because nothing in the request/response handling reads it.

What actually happens on a real error response

The generated runtime.ts handles non-2xx responses like this:

protected async request(context: RequestOpts, initOverrides?): Promise<Response> {
    const response = await this.fetchApi(url, init);
    if (response && (response.status >= 200 && response.status < 300)) {
        return response;
    }
    throw new ResponseError(response, 'Response returned an error code');
}

export class ResponseError extends Error {
    override name: "ResponseError" = "ResponseError";
    constructor(public response: Response, msg?: string) { super(msg); ... }
}

Any status the default response was written to describe — a real 404, 429, or 500 — throws a ResponseError carrying the raw, unread Response object and a fixed message string. The caller gets no typed access to the code/message fields the Error schema defined; extracting them requires manually calling error.response.json() and casting the result by hand, with the generated ModelError type sitting unused right next to the code that needs it.

Why does this happen?

Per the OpenAPI 3.1.1 specification's Responses Object, default is "the documentation of responses other than the ones declared for specific HTTP response codes... use this field to cover undeclared responses," and an explicit status code always takes precedence over it when both are present. That's a correct description of what default means in the spec — but it doesn't map cleanly onto a single typed return value the way an explicit 200 does, because default by definition covers an open set of status codes the generator can't enumerate at generation time. Rather than model that as a union return type (Message | Error, with the caller left to discriminate by catching and checking), the typescript-fetch template we tested only uses the first declared 2xx-range response to build the success return type and treats everything else — default included — as belonging to the generic thrown-error path.

How do you fix it?

You can't force a generator to build the union type for you, but you can make the error shape reachable at the call site instead of buried in an unused model:

try {
  const message = await api.getMessage({ id: "msg_123" });
  // message: Message
} catch (err) {
  if (err instanceof runtime.ResponseError) {
    const body = await err.response.json();
    const apiError = ModelErrorFromJSON(body); // manually reach for the generated type
    console.error(apiError.code, apiError.message);
  }
  throw err;
}

If you control the spec and want callers to get real typed errors without hand-parsing, declare explicit status codes instead of relying on default for anything you expect callers to branch on — a generator is far more likely to expose a documented 4XX/5XX code as a distinct, typed response than it is to expose default:

responses:
  "200": { ... }
  "404":
    description: Not found
    content:
      application/json:
        schema: { $ref: '#/components/schemas/Error' }
  default:
    description: Unexpected error
    content:
      application/json:
        schema: { $ref: '#/components/schemas/Error' }

Whether an explicit 4XX gets a distinct typed branch instead of the generic throw still depends on the generator and template — verify it against your actual generator, the same way we did here, rather than assuming.

A second failure mode: default as the only response

A spec with no explicit success code, only default, produces a different problem. We tested this too — openapi-generator treats the default schema as the method's success type outright:

async getMessage(...): Promise<Message> {  // Message here is `default`'s schema

This "works" for an actual 200 response at runtime, since the fetch-level status check (response.status >= 200 && response.status < 300) still passes for a real success — but it means a spec author who forgot to add an explicit 200 gets a fully working, fully typed client anyway, with no signal that default was ever supposed to mean "everything I didn't declare" rather than "the success case."

How different tools react

Tool default alongside explicit 200 default as the only response
redocly lint (semantic linter) Valid — only unrelated style warnings Valid — only unrelated style warnings
openapi-generator typescript-fetch Return type ignores default; Error model generated but unused default's schema used as the success return type
Runtime (real error response) Throws untyped ResponseError; body unread N/A
Runtime (real success response) Typed Message, as expected Typed Message-equivalent, as expected

Sourced's compatibility report flags when a default response's schema has no reachable typed path in the generated client, so an unused error model isn't a silent gap you find while debugging a production incident. Create hosted docs from your repo or start a free report to check your own response typing.

FAQ

Should I use default or explicit status codes for errors?

Use explicit codes (404, 429, 500, etc.) for anything a caller needs to branch on programmatically, and keep default as a true catch-all for genuinely unanticipated statuses. As shown above, default gets dropped from the generated return type entirely by the generator we tested — explicit codes are far more likely to be handled distinctly, though that's generator-dependent and worth verifying against your own toolchain.

Does every generator drop default from the return type the same way?

We verified this specifically with openapi-generator's typescript-fetch template. Other generators and other languages may model default differently (some server-side generators use it to build an exhaustive error-handling switch); don't assume the client-side TypeScript behavior documented here applies to every generator without checking your own.

Is it a spec bug that default doesn't produce a typed error in TypeScript?

No — the OpenAPI spec correctly defines what default means; how a specific codegen template turns that into a language's type system is left entirely to the generator's implementation. This is a tooling gap, not a spec defect.

Why is the generated model called ModelError instead of Error?

Error is a reserved global type in JavaScript/TypeScript, so openapi-generator's TypeScript templates rename any schema literally named Error to ModelError to avoid a naming collision. Naming your schema something else (ApiError, ProblemDetails) avoids the rename and makes the generated code easier to search for.

Does having both default and an explicit 4XX cause a conflict?

No — per the spec, an explicit status code always takes precedence over default for that specific code; default only applies to codes with no explicit entry. They coexist safely in the spec itself; the gap covered here is purely about what the generated client does with that information.

How do I know if my generated SDK actually uses my default response schema?

Grep the generated client source for the schema's generated type name (e.g. ModelErrorFromJSON or wherever your generator names it) outside its own model file. If it only appears in its own definition, as in the reproduction above, it's dead code — present in the SDK but never reachable through any typed return value.