DEV Community

Cover image for FastAPI's detail has two shapes, and the second one shows your users [object Object]
Juan Camilo Auriti
Juan Camilo Auriti

Posted on AI-assisted

FastAPI's detail has two shapes, and the second one shows your users [object Object]

A user hit a validation error on a form and the page told them:

[object Object]
Enter fullscreen mode Exit fullscreen mode

The frontend line responsible is one you have written, probably today:

errorEl.textContent = data.detail || "Something went wrong"
Enter fullscreen mode Exit fullscreen mode

That line is correct for every error my API raises deliberately, and broken for an entire class of error I never wrote.

Two shapes

When you raise it yourself, detail is whatever you passed — normally a string:

raise HTTPException(status_code=403, detail="This domain isn't yours")
Enter fullscreen mode Exit fullscreen mode
{ "detail": "This domain isn't yours" }
Enter fullscreen mode Exit fullscreen mode

When Pydantic rejects the request body, FastAPI builds the 422 for you, and detail is an array of objects:

class AuditRequest(BaseModel):
    url: HttpUrl

    @field_validator("url")
    @classmethod
    def no_private_hosts(cls, v):
        if is_private(v.host):
            raise ValueError("private addresses are not allowed")
        return v
Enter fullscreen mode Exit fullscreen mode
{
  "detail": [
    {
      "type": "value_error",
      "loc": ["body", "url"],
      "msg": "Value error, private addresses are not allowed",
      "input": "http://169.254.169.254/"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Same key. Same status family. Two incompatible types.

Why the || doesn't save you

data.detail || fallback is a truthiness check, and a non-empty array is truthy. So the fallback never fires — the array wins, and textContent stringifies it with Array.prototype.toString, which joins the elements, each of which stringifies as [object Object].

One validation error prints [object Object]. Two print [object Object],[object Object], which at least looks broken enough that someone files a bug.

This is the specific reason it survives to production: the failure needs a request that is malformed, not merely rejected. Every hand-raised error works. Every happy path works. Your tests assert on status codes and on detail for the errors you wrote. Nobody writes a test asserting on how a validator's error message renders in the DOM.

The unroller

Handle both shapes once, at the boundary where the response becomes a message:

export function extractError(data, fallback = "Something went wrong") {
  const d = data?.detail
  if (typeof d === "string" && d.trim()) return d
  if (Array.isArray(d)) {
    const msgs = d
      .map(e => {
        const field = Array.isArray(e?.loc) ? e.loc.filter(p => p !== "body").join(".") : ""
        const msg = typeof e?.msg === "string" ? e.msg : ""
        if (!msg) return ""
        return field ? `${field}: ${msg}` : msg
      })
      .filter(Boolean)
    if (msgs.length) return msgs.join(" · ")
  }
  if (typeof d === "string") return fallback   // empty/whitespace string
  return fallback
}
Enter fullscreen mode Exit fullscreen mode

The parts that are there because I got them wrong first:

loc.filter(p => p !== "body"). The raw loc is ["body", "url"]. Showing a user body.url: ... leaks your request schema into the UI for no benefit. Drop the container, keep the field.

Guard every element. e.msg is a string in every FastAPI version I've used, but this function's entire job is to survive a shape it didn't expect. A .map that assumes will throw inside your error handler, which is the worst possible place to throw.

.filter(Boolean) then check length. If every element yields an empty message you must fall through to the fallback, not return "" — an empty string assigned to textContent produces a silent, blank error state, which is worse than the wrong message because nobody reports it.

Trim the string case. detail: " " is truthy and renders as nothing. Same silent-blank failure.

Or flatten it server-side

If you own both ends, the other fix is to stop emitting the union at all:

from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse

@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
    parts = []
    for e in exc.errors():
        field = ".".join(str(p) for p in e["loc"] if p != "body")
        parts.append(f"{field}: {e['msg']}" if field else e["msg"])
    return JSONResponse(status_code=422, content={"detail": " · ".join(parts)})
Enter fullscreen mode Exit fullscreen mode

Now detail is always a string and the original one-liner is correct.

I did both. The handler is the real fix — one type, one contract. extractError stays because a frontend that trusts a single shape is one upstream change away from [object Object] again, and because third-party middleware can produce a 422 that never reaches my handler.

The transferable bit

detail is a documented union type that reads like a scalar. FastAPI isn't doing anything wrong; it's in the spec. The trap is that JavaScript's truthiness makes a union look handled when only one arm of it is.

So: anywhere you write x || fallback against a value that crosses a network boundary, the question isn't "can it be missing" — || has that covered. It's "can it be present and the wrong type". That's the case || silently passes through to the DOM.

Fastest way to find yours: point your form's own validator at a malformed request and look at what your UI actually says. Mine said [object Object] for weeks, in a field a user reads only when they're already stuck.

Top comments (1)

Collapse
 
elijahbrown profile image
Elijah Brown •

The [object Object] trap is a good catch. Same class of silent failure on contact forms: a well-formed email on a domain with no MX still looks successful until the confirmation bounces, so the server needs a DNS check, not only a shape check.