Type-Driven Responses — How Chirp Eliminates make_response()

In Chirp, the return type is the intent. Template, Fragment, Page, Stream, Suspense, EventStream — each type maps to exactly one response shape. No conditionals. No make_response().

This post is about a small idea with large consequences: in Chirp, the return type is the response contract.

In Flask, the handler knows what it wants to return, but the framework often needs extra hints. That gap lives in make_response(), render_template(), and the if request.headers.get("HX-Request"): check you write in every htmx route.

Forget that check once and your htmx swap renders the full page inside a div.

Chirp inverts this. The handler returns a type, such as Template, Fragment, Page, Stream, Suspense, or EventStream, and the framework's content negotiator decides the response shape. The return type is the intent.


The negotiation model

When a route handler returns, Chirp's negotiate() function dispatches using structural pattern matching:

match value:
    case Response():      # pass through
    case Redirect():      # 302 + Location
    case Template():      # full page render
    case Fragment():      # block render
    case Page():          # full or fragment based on request
    case Action():        # empty body + HX headers
    case ValidationError(): # 422 + fragment
    case OOB():           # primary + hx-swap-oob
    case Stream():        # progressive HTML
    case Suspense():      # shell + deferred OOB
    case EventStream():   # SSE
    case str():           # text/html
    case dict() | list(): # JSON

No if-chains. No isinstance ladders. Each case maps to exactly one response shape.


Why this matters for htmx

This is easiest to appreciate in htmx apps. The same route often needs to return a full page or a fragment depending on whether the request is a browser navigation or an htmx swap.

In Flask:

@app.route("/search")
def search():
    books = find_books(request.args.get("q", ""))
    if request.headers.get("HX-Request"):
        return render_template("search.html", books=books, block="results")
    return render_template("search.html", books=books)

In Chirp:

@app.route("/search")
def search(request: Request):
    books = find_books(request.query.get("q", ""))
    return Page("search.html", "results", books=books, query=request.query.get("q"))

Page encapsulates the decision. If the request has HX-Request: true and is not a history restore, the negotiator renders just the results block. Otherwise, it renders the full template.

Same data. Same template. The branch moves out of the handler and into the contract.


The return types

List table has no rows

Real examples

Fragment for htmx CRUD

@app.route("/tasks", methods=["POST"])
def create_task(request: Request):
    task = store.add(request.form["title"])
    return Fragment("tasks.html", "task_item", task=task)

The handler does not think about response objects, content types, or status codes. It returns the thing it wants to happen.

OOB for multi-region updates

@app.route("/tasks/{id}/complete", methods=["POST"])
def complete_task(id: int):
    task = store.complete(id)
    return OOB(
        Fragment("tasks.html", "task_item", task=task),
        Fragment("tasks.html", "stats", stats=store.stats()),
    )

One return. Two regions update. The negotiator handles the hx-swap-oob wrapping.

Suspense for instant first paint

@app.route("/dashboard")
async def dashboard():
    return Suspense(
        "dashboard.html",
        shell="shell",
        blocks={
            "metrics": fetch_metrics(),
            "activity": fetch_activity(),
        },
    )

The shell renders immediately. Deferred blocks resolve concurrently and stream in as OOB swaps. The user sees content immediately, and heavier data fills in without waiting for a full page render.

FormAction — one handler, two clients

@app.route("/tasks", methods=["POST"])
def create_task(request: Request):
    task = store.add(request.form["title"])
    return FormAction(
        redirect="/tasks",
        fragments=[Fragment("tasks.html", "task_list", tasks=store.all())],
    )
  • htmx request → renders the fragments, returns HTML
  • Browser POST → 303 redirect to /tasks

One handler. Both clients work correctly.


What the negotiator doesn't do

It does not guess. Every return type maps to exactly one response strategy. No content-type sniffing, no Accept header parsing, no fallback chains. If you return Fragment, you get a fragment. If you return dict, you get JSON.

Implicit negotiation, like Rails' respond_to, creates behavior that is harder to predict and harder to debug. Chirp's negotiation is explicit: the type is the contract.

The negotiator is ~250 lines of code. No inheritance, no visitor pattern, no registry. Adding a new return type means defining the dataclass, adding a case branch, and updating the contract checks. That's it.


Further reading