Skip to content

feat(endpoint): add ctx.json helper for JSON responses#42

Open
halvaradop wants to merge 1 commit intomasterfrom
feat/add-json-function
Open

feat(endpoint): add ctx.json helper for JSON responses#42
halvaradop wants to merge 1 commit intomasterfrom
feat/add-json-function

Conversation

@halvaradop
Copy link
Copy Markdown
Member

@halvaradop halvaradop commented Mar 18, 2026

Description

ctx.json

Summary by CodeRabbit

  • New Features

    • Added json method to request context for type-safe JSON responses.
    • Enhanced route handlers to support multiple return types with automatic format conversion (strings, streams, primitives, JSON objects).
    • Improved type inference for endpoint responses.
  • Tests

    • Updated test fixtures to validate new response handling.

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 18, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
router Ready Ready Preview, Comment Mar 18, 2026 2:59am

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 18, 2026

📝 Walkthrough

Walkthrough

The changes introduce type-safe JSON response handling for route handlers by adding new utility types and helper functions. A json helper is added to the request context, new types are defined to infer handler return types and JSON response payloads, and the router is refactored to properly translate handler results to HTTP responses using a new inferHandlerResponse function.

Changes

Cohort / File(s) Summary
Type System Enhancements
src/types.ts
Introduces JsonResponse<T>, RouteHandlerReturn, and type inference utilities (InferRouteHandlerReturn, InferRouteHandlerJsonResponse, RouteEndpointResponse). Extends RequestContext with json method and generalizes RouteHandler/RouteEndpoint with return type parameters to enable richer type inference.
Core Handler Infrastructure
src/context.ts, src/endpoint.ts
Adds json helper function that wraps Response.json. Updates createEndpoint signature to accept and preserve a typed Handler generic parameter.
Request Processing Pipeline
src/router.ts
Introduces inferHandlerResponse to normalize various handler results (Response, undefined, string, streams, objects) into HTTP responses. Restructures handleRequest to route handler results through inference logic and adds json to request context payload.
Tests
test/client.test.ts, test/type.test-d.ts
Updates test endpoint to return JSON payload instead of empty response. Validates new public types (JsonResponse, RouteEndpointResponse, InferEndpoints) and demonstrates ctx.json usage pattern.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Router as Router<br/>(handleRequest)
    participant Context as RequestContext
    participant Handler as Route Handler
    participant Inference as inferHandlerResponse

    Client->>Router: HTTP Request
    activate Router
    Router->>Context: Create GlobalRequestContext<br/>(with json helper)
    activate Context
    Router->>Handler: Execute handler<br/>(ctx)
    deactivate Context
    activate Handler
    Handler->>Context: Call ctx.json(data)
    activate Context
    Context-->>Handler: JsonResponse<T>
    deactivate Context
    Handler-->>Inference: Return JsonResponse | Response |<br/>string | undefined | etc.
    deactivate Handler
    activate Inference
    Inference->>Inference: Normalize result:<br/>- Response → keep<br/>- undefined → 204<br/>- string → text/plain<br/>- object/array → JSON
    Inference-->>Router: HTTP Response
    deactivate Inference
    Router-->>Client: Response
    deactivate Router
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested labels

enhancement

Poem

🐰 A json helper hops through the code,
With types that guide each request's road,
Handler returns now neatly dressed,
In JsonResponse—type-safe and blessed!
Routes infer what they should be,
A stronger API—wild and free! 🚀

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: introducing a ctx.json helper for JSON responses, which is reflected across all modified files (context.ts, types.ts, endpoint.ts, router.ts) and test files.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/add-json-function
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

CodeRabbit can generate a title for your PR based on the changes.

Add @coderabbitai placeholder anywhere in the title of your PR and CodeRabbit will replace it with a title based on the changes in the PR. You can change the placeholder by changing the reviews.auto_title_placeholder setting.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
test/type.test-d.ts (1)

465-471: Assert RouteEndpointResponse<typeof endpoint> in this case.

This block only proves the handler keeps its JsonResponse return. It doesn't exercise the new exported helper type, so RouteEndpointResponse can regress without failing this test.

🧪 Suggested addition
     expectTypeOf<ReturnType<typeof endpoint.handler>>().toEqualTypeOf<JsonResponse<{ id: number }>>()
+    expectTypeOf<RouteEndpointResponse<typeof endpoint>>().toEqualTypeOf<{ id: number }>()
 
     const router = createRouter([endpoint])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/type.test-d.ts` around lines 465 - 471, Add an assertion that verifies
the exported helper type RouteEndpointResponse uses the same inferred response
as the endpoint: after creating endpoint with createEndpoint("GET", "/test",
(ctx) => ctx.json({ id: 1 })), add an expectTypeOf<RouteEndpointResponse<typeof
endpoint>>().toEqualTypeOf<JsonResponse<{ id: number }>>() (or add it alongside
the existing handler return assertion) so the test exercises
RouteEndpointResponse and will break if that helper regresses; reference the
symbols endpoint, RouteEndpointResponse, JsonResponse, and createEndpoint when
adding the new expectTypeOf assertion.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/types.ts`:
- Around line 112-145: The JsonResponse<T> type is structurally ambiguous with
the DOM Response because Response.json(): Promise<any> matches the shape; add a
unique nominal brand (e.g., a readonly unique symbol property) to
JsonResponse<T> so it no longer matches plain Response, and update the json
helper in src/context.ts to return an object that includes that same brand so
InferRouteHandlerJsonResponse correctly extracts T; ensure the brand symbol name
is exported/used consistently between the JsonResponse<T> definition and the
json helper (refer to JsonResponse, InferRouteHandlerJsonResponse, and the json
helper).

---

Nitpick comments:
In `@test/type.test-d.ts`:
- Around line 465-471: Add an assertion that verifies the exported helper type
RouteEndpointResponse uses the same inferred response as the endpoint: after
creating endpoint with createEndpoint("GET", "/test", (ctx) => ctx.json({ id: 1
})), add an expectTypeOf<RouteEndpointResponse<typeof
endpoint>>().toEqualTypeOf<JsonResponse<{ id: number }>>() (or add it alongside
the existing handler return assertion) so the test exercises
RouteEndpointResponse and will break if that helper regresses; reference the
symbols endpoint, RouteEndpointResponse, JsonResponse, and createEndpoint when
adding the new expectTypeOf assertion.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 95a0e769-a3d1-475a-8d6e-f477a8de1126

📥 Commits

Reviewing files that changed from the base of the PR and between db4a175 and 90ebe30.

📒 Files selected for processing (6)
  • src/context.ts
  • src/endpoint.ts
  • src/router.ts
  • src/types.ts
  • test/client.test.ts
  • test/type.test-d.ts

Comment thread src/types.ts
Comment on lines +112 to +145
export type JsonResponse<T> = Response & {
json(): Promise<T>
}

export type RouteHandlerReturn =
| Response
| JsonResponse<unknown>
| string
| number
| boolean
| null
| undefined
| Record<string, unknown>
| unknown[]
| BodyInit

export type InferRouteHandlerReturn<Handler> = Handler extends (...args: unknown[]) => infer R
? R extends Promise<infer U>
? U
: R
: never

export type InferRouteHandlerJsonResponse<Handler> = Handler extends (...args: unknown[]) => infer R
? R extends Promise<infer U>
? U extends JsonResponse<infer Data>
? Data
: never
: R extends JsonResponse<infer Data>
? Data
: never
: never

export type RouteEndpointResponse<Endpoint extends { handler: (...args: unknown[]) => any }> = InferRouteHandlerJsonResponse<Endpoint["handler"]>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In TypeScript, is a plain Responseassignable toResponse & { json(): Promise }becauseResponse.json()returnsPromise? What unique symbolbranding pattern is recommended to distinguish a specializedResponse subtype in conditional types?

💡 Result:

No, a plain Response is assignable to Response & { json: Promise } for any T. TypeScript's standard DOM library defines Response.json: Promise, so the plain Response already satisfies having a json method returning Promise. Since Promise is assignable to Promise for any T (due to bivariant Promise generics in TypeScript), the intersection type is structurally compatible with plain Response. To distinguish a specialized Response subtype (e.g., one with json: Promise for specific T) in conditional types, use the unique symbol branding pattern: declare const brand: unique symbol; type BrandedResponse = Response & { json: Promise } & { readonly [brand]: T }; type IsTypedResponse = R extends BrandedResponse ? T : never; This creates nominal-like distinction: only Response values explicitly branded with the symbol (via type assertion after validation) will extract the T in conditional types. Plain Response lacks the brand property, so IsTypedResponse = never. Example usage: function typedJson(res: Response): BrandedResponse { // Validate res.ok, content-type, etc. return res as BrandedResponse; } type ExtractT = R extends BrandedResponse ? T : never; const typed = typedJson<{ foo: string }> fetchResult); type FooType = ExtractT; // { foo: string } type PlainType = ExtractT; // never This pattern uses unique symbols to avoid structural typing pitfalls, as recommended across TypeScript community resources on branded types.

Citations:


🏁 Script executed:

cat -n src/types.ts | sed -n '112,145p'

Repository: aura-stack-ts/router

Length of output: 1200


🏁 Script executed:

rg -n "InferRouteHandlerJsonResponse|RouteEndpointResponse|JsonResponse" src/

Repository: aura-stack-ts/router

Length of output: 989


🏁 Script executed:

rg -A 5 -B 5 "InferRouteHandlerJsonResponse" src/endpoint.ts

Repository: aura-stack-ts/router

Length of output: 346


🏁 Script executed:

cat -n src/endpoint.ts | head -100

Repository: aura-stack-ts/router

Length of output: 4417


🏁 Script executed:

rg -n "RouteEndpointResponse" src/

Repository: aura-stack-ts/router

Length of output: 226


🏁 Script executed:

rg -n "export.*RouteEndpointResponse|export.*InferRouteHandlerJsonResponse" src/

Repository: aura-stack-ts/router

Length of output: 344


🏁 Script executed:

rg -n "createEndpoint" src/ | head -20

Repository: aura-stack-ts/router

Length of output: 1259


🏁 Script executed:

cat -n src/index.ts

Repository: aura-stack-ts/router

Length of output: 479


🏁 Script executed:

git ls-files --others --exclude-standard | head -20

Repository: aura-stack-ts/router

Length of output: 74


🏁 Script executed:

fd -e "test\|spec" src/

Repository: aura-stack-ts/router

Length of output: 385


🏁 Script executed:

fd "test|spec" src/

Repository: aura-stack-ts/router

Length of output: 46


🏁 Script executed:

cat -n src/types.ts | sed -n '145,200p'

Repository: aura-stack-ts/router

Length of output: 3048


🏁 Script executed:

rg -n "typeof.*Handler|type.*Handler" src/types.ts | head -30

Repository: aura-stack-ts/router

Length of output: 764


🏁 Script executed:

cat -n src/context.ts | sed -n '150,160p'

Repository: aura-stack-ts/router

Length of output: 340


🏁 Script executed:

cat -n src/types.ts | sed -n '220,240p'

Repository: aura-stack-ts/router

Length of output: 1025


🏁 Script executed:

rg -n "InferRouteHandlerJsonResponse|RouteEndpointResponse" src/router.ts

Repository: aura-stack-ts/router

Length of output: 46


Add branding to JsonResponse<T> to prevent structural type ambiguity.

Response.json() already returns Promise<any> in the DOM types, so plain Response structurally satisfies JsonResponse<T>. This causes InferRouteHandlerJsonResponse to extract any for handlers returning a normal Response, undermining the inference API. A unique symbol brand fixes this false positive:

Recommended fix
+declare const jsonResponseBrand: unique symbol
-export type JsonResponse<T> = Response & {
-  json(): Promise<T>
-}
+export interface JsonResponse<T> extends Response {
+    readonly [jsonResponseBrand]: T
+    json(): Promise<T>
+}

Then update the json helper in src/context.ts to apply the brand:

-export const json = <T>(data: T, init?: ResponseInit): JsonResponse<T> => {
-  return Response.json(data, init) as JsonResponse<T>;
+const brand = Symbol('JsonResponse');
+export const json = <T>(data: T, init?: ResponseInit): JsonResponse<T> => {
+  return Object.assign(Response.json(data, init), { [brand]: undefined }) as JsonResponse<T>;
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/types.ts` around lines 112 - 145, The JsonResponse<T> type is
structurally ambiguous with the DOM Response because Response.json():
Promise<any> matches the shape; add a unique nominal brand (e.g., a readonly
unique symbol property) to JsonResponse<T> so it no longer matches plain
Response, and update the json helper in src/context.ts to return an object that
includes that same brand so InferRouteHandlerJsonResponse correctly extracts T;
ensure the brand symbol name is exported/used consistently between the
JsonResponse<T> definition and the json helper (refer to JsonResponse,
InferRouteHandlerJsonResponse, and the json helper).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant