From 7497a6db59e94d32c669762397fef311e85dc254 Mon Sep 17 00:00:00 2001 From: Michael Nahkies Date: Wed, 18 Mar 2026 17:54:53 +1300 Subject: [PATCH] fix(fetch): type narrowing with default response types --- e2e/openapi.yaml | 29 ++++++++ e2e/src/generated/client/axios/client.ts | 24 +++++++ e2e/src/generated/client/axios/models.ts | 8 +++ e2e/src/generated/client/axios/schemas.ts | 8 +++ e2e/src/generated/client/fetch/client.ts | 31 ++++++++ e2e/src/generated/client/fetch/models.ts | 8 +++ e2e/src/generated/client/fetch/schemas.ts | 8 +++ e2e/src/generated/server/express/models.ts | 12 ++++ .../server/express/routes/validation.ts | 72 +++++++++++++++++++ e2e/src/generated/server/express/schemas.ts | 8 +++ e2e/src/generated/server/koa/models.ts | 12 ++++ .../generated/server/koa/routes/validation.ts | 66 +++++++++++++++++ e2e/src/generated/server/koa/schemas.ts | 8 +++ e2e/src/index.axios.spec.ts | 23 ++++++ e2e/src/index.fetch.spec.ts | 26 +++++++ e2e/src/routes/express/validation.ts | 13 ++++ e2e/src/routes/koa/validation.ts | 13 ++++ 17 files changed, 369 insertions(+) diff --git a/e2e/openapi.yaml b/e2e/openapi.yaml index c541c3970..60b07e7a9 100644 --- a/e2e/openapi.yaml +++ b/e2e/openapi.yaml @@ -277,6 +277,35 @@ paths: responses: 500: description: Internal Server Error + /responses/default: + get: + tags: + - validation + parameters: + - name: status + in: query + schema: + type: string + enum: [200,500] + responses: + 200: + description: ok + content: + application/json: + schema: + type: object + properties: + id: + type: string + default: + description: Error + content: + application/json: + schema: + type: object + properties: + error: + type: string /responses/empty: get: tags: diff --git a/e2e/src/generated/client/axios/client.ts b/e2e/src/generated/client/axios/client.ts index 777cb22fc..251e1f6c4 100644 --- a/e2e/src/generated/client/axios/client.ts +++ b/e2e/src/generated/client/axios/client.ts @@ -17,6 +17,7 @@ import type { t_GetParamsMixedQuery200Response, t_GetParamsSimpleQuery200Response, t_GetParamsUnexplodedObjectQuery200Response, + t_GetResponsesDefault200Response, t_PostValidationOptionalBody200Response, t_PostValidationOptionalBodyRequestBody, t_ProductOrder, @@ -31,6 +32,7 @@ import { s_GetParamsMixedQuery200Response, s_GetParamsSimpleQuery200Response, s_GetParamsUnexplodedObjectQuery200Response, + s_GetResponsesDefault200Response, s_PostValidationOptionalBody200Response, s_ProductOrder, s_RandomNumber, @@ -369,6 +371,28 @@ export class E2ETestClient extends AbstractAxiosClient { }) } + async getResponsesDefault( + p: { + status?: "200" | "500" | UnknownEnumStringValue + } = {}, + timeout?: number, + opts: AxiosRequestConfig = {}, + ): Promise> { + const url = `/responses/default` + const headers = this._headers({Accept: "application/json"}, opts.headers) + const query = this._query({status: p["status"]}) + + const res = await this._request({ + url: url + query, + method: "GET", + ...(timeout ? {timeout} : {}), + ...opts, + headers, + }) + + return {...res, data: s_GetResponsesDefault200Response.parse(res.data)} + } + async getResponsesEmpty( timeout?: number, opts: AxiosRequestConfig = {}, diff --git a/e2e/src/generated/client/axios/models.ts b/e2e/src/generated/client/axios/models.ts index 92ba4d08b..d442bc8fd 100644 --- a/e2e/src/generated/client/axios/models.ts +++ b/e2e/src/generated/client/axios/models.ts @@ -71,6 +71,14 @@ export type t_GetParamsUnexplodedObjectQuery200Response = { } } +export type t_GetResponsesDefault200Response = { + id?: string | undefined +} + +export type t_GetResponsesDefaultdefaultResponse = { + error?: string | undefined +} + export type t_PostValidationOptionalBody200Response = { id?: string | undefined } diff --git a/e2e/src/generated/client/axios/schemas.ts b/e2e/src/generated/client/axios/schemas.ts index e1a8819e2..71f5b31ac 100644 --- a/e2e/src/generated/client/axios/schemas.ts +++ b/e2e/src/generated/client/axios/schemas.ts @@ -68,3 +68,11 @@ export const s_GetParamsMixedQuery200Response = z.object({ export const s_PostValidationOptionalBody200Response = z.object({ id: z.string().optional(), }) + +export const s_GetResponsesDefault200Response = z.object({ + id: z.string().optional(), +}) + +export const s_GetResponsesDefaultdefaultResponse = z.object({ + error: z.string().optional(), +}) diff --git a/e2e/src/generated/client/fetch/client.ts b/e2e/src/generated/client/fetch/client.ts index 62fcae4e8..c1581ac48 100644 --- a/e2e/src/generated/client/fetch/client.ts +++ b/e2e/src/generated/client/fetch/client.ts @@ -7,6 +7,7 @@ import { type AbstractFetchClientConfig, type Res, type Server, + type StatusCode, } from "@nahkies/typescript-fetch-runtime/main" import {responseValidationFactory} from "@nahkies/typescript-fetch-runtime/zod-v4" import {z} from "zod/v4" @@ -18,6 +19,8 @@ import type { t_GetParamsMixedQuery200Response, t_GetParamsSimpleQuery200Response, t_GetParamsUnexplodedObjectQuery200Response, + t_GetResponsesDefault200Response, + t_GetResponsesDefaultdefaultResponse, t_PostValidationOptionalBody200Response, t_PostValidationOptionalBodyRequestBody, t_ProductOrder, @@ -32,6 +35,8 @@ import { s_GetParamsMixedQuery200Response, s_GetParamsSimpleQuery200Response, s_GetParamsUnexplodedObjectQuery200Response, + s_GetResponsesDefault200Response, + s_GetResponsesDefaultdefaultResponse, s_PostValidationOptionalBody200Response, s_ProductOrder, s_RandomNumber, @@ -353,6 +358,32 @@ export class E2ETestClient extends AbstractFetchClient { return responseValidationFactory([["500", z.any()]], undefined)(res) } + async getResponsesDefault( + p: { + status?: "200" | "500" | UnknownEnumStringValue + } = {}, + timeout?: number, + opts: RequestInit = {}, + ): Promise< + | Res<200, t_GetResponsesDefault200Response> + | Res + > { + const url = this.basePath + `/responses/default` + const headers = this._headers({Accept: "application/json"}, opts.headers) + const query = this._query({status: p["status"]}) + + const res = this._fetch( + url + query, + {method: "GET", ...opts, headers}, + timeout, + ) + + return responseValidationFactory( + [["200", s_GetResponsesDefault200Response]], + s_GetResponsesDefaultdefaultResponse, + )(res) + } + async getResponsesEmpty( timeout?: number, opts: RequestInit = {}, diff --git a/e2e/src/generated/client/fetch/models.ts b/e2e/src/generated/client/fetch/models.ts index 92ba4d08b..d442bc8fd 100644 --- a/e2e/src/generated/client/fetch/models.ts +++ b/e2e/src/generated/client/fetch/models.ts @@ -71,6 +71,14 @@ export type t_GetParamsUnexplodedObjectQuery200Response = { } } +export type t_GetResponsesDefault200Response = { + id?: string | undefined +} + +export type t_GetResponsesDefaultdefaultResponse = { + error?: string | undefined +} + export type t_PostValidationOptionalBody200Response = { id?: string | undefined } diff --git a/e2e/src/generated/client/fetch/schemas.ts b/e2e/src/generated/client/fetch/schemas.ts index e1a8819e2..71f5b31ac 100644 --- a/e2e/src/generated/client/fetch/schemas.ts +++ b/e2e/src/generated/client/fetch/schemas.ts @@ -68,3 +68,11 @@ export const s_GetParamsMixedQuery200Response = z.object({ export const s_PostValidationOptionalBody200Response = z.object({ id: z.string().optional(), }) + +export const s_GetResponsesDefault200Response = z.object({ + id: z.string().optional(), +}) + +export const s_GetResponsesDefaultdefaultResponse = z.object({ + error: z.string().optional(), +}) diff --git a/e2e/src/generated/server/express/models.ts b/e2e/src/generated/server/express/models.ts index d68fc9c4d..ec2870815 100644 --- a/e2e/src/generated/server/express/models.ts +++ b/e2e/src/generated/server/express/models.ts @@ -95,6 +95,18 @@ export type t_GetParamsUnexplodedObjectQueryQuerySchema = { } } +export type t_GetResponsesDefault200Response = { + id?: string | undefined +} + +export type t_GetResponsesDefaultQuerySchema = { + status?: ("200" | "500") | undefined +} + +export type t_GetResponsesDefaultdefaultResponse = { + error?: string | undefined +} + export type t_GetValidationNumbersRandomNumberQuerySchema = { forbidden?: number[] | undefined max?: number | undefined diff --git a/e2e/src/generated/server/express/routes/validation.ts b/e2e/src/generated/server/express/routes/validation.ts index 21235a432..27a4f7117 100644 --- a/e2e/src/generated/server/express/routes/validation.ts +++ b/e2e/src/generated/server/express/routes/validation.ts @@ -20,6 +20,9 @@ import {type NextFunction, type Request, type Response, Router} from "express" import {z} from "zod/v4" import type { t_Enumerations, + t_GetResponsesDefault200Response, + t_GetResponsesDefaultdefaultResponse, + t_GetResponsesDefaultQuerySchema, t_GetValidationNumbersRandomNumberQuerySchema, t_PostValidationOptionalBody200Response, t_PostValidationOptionalBodyRequestBody, @@ -27,6 +30,8 @@ import type { } from "../models.ts" import { s_Enumerations, + s_GetResponsesDefault200Response, + s_GetResponsesDefaultdefaultResponse, s_PostValidationOptionalBody200Response, s_PostValidationOptionalBodyRequestBody, s_RandomNumber, @@ -91,6 +96,21 @@ export type GetResponses500 = ( next: NextFunction, ) => Promise | typeof SkipResponse> +export type GetResponsesDefaultResponder = { + with200(): ExpressRuntimeResponse + withDefault( + status: StatusCode, + ): ExpressRuntimeResponse +} & ExpressRuntimeResponder + +export type GetResponsesDefault = ( + params: Params, + respond: GetResponsesDefaultResponder, + req: Request, + res: Response, + next: NextFunction, +) => Promise | typeof SkipResponse> + export type GetResponsesEmptyResponder = { with204(): ExpressRuntimeResponse } & ExpressRuntimeResponder @@ -108,6 +128,7 @@ export type ValidationImplementation = { postValidationEnums: PostValidationEnums postValidationOptionalBody: PostValidationOptionalBody getResponses500: GetResponses500 + getResponsesDefault: GetResponsesDefault getResponsesEmpty: GetResponsesEmpty } @@ -300,6 +321,57 @@ export function createValidationRouter( }, ) + const getResponsesDefaultQuerySchema = z.object({ + status: z.enum(["200", "500"]).optional(), + }) + + const getResponsesDefaultResponseBodyValidator = responseValidationFactory( + [["200", s_GetResponsesDefault200Response]], + s_GetResponsesDefaultdefaultResponse, + ) + + // getResponsesDefault + router.get( + `/responses/default`, + async (req: Request, res: Response, next: NextFunction) => { + try { + const input = { + params: undefined, + query: parseRequestInput( + getResponsesDefaultQuerySchema, + req.query, + RequestInputType.QueryString, + ), + body: undefined, + headers: undefined, + } + + const responder = { + with200() { + return new ExpressRuntimeResponse( + 200, + ) + }, + withDefault(status: StatusCode) { + return new ExpressRuntimeResponse( + status, + ) + }, + withStatus(status: StatusCode) { + return new ExpressRuntimeResponse(status) + }, + } + + await implementation + .getResponsesDefault(input, responder, req, res, next) + .catch(handleImplementationError) + .then(handleResponse(res, getResponsesDefaultResponseBodyValidator)) + } catch (error) { + next(error) + } + }, + ) + const getResponsesEmptyResponseBodyValidator = responseValidationFactory( [["204", z.undefined()]], undefined, diff --git a/e2e/src/generated/server/express/schemas.ts b/e2e/src/generated/server/express/schemas.ts index 69f49cba8..e7fda34aa 100644 --- a/e2e/src/generated/server/express/schemas.ts +++ b/e2e/src/generated/server/express/schemas.ts @@ -72,3 +72,11 @@ export const s_PostValidationOptionalBodyRequestBody = z.object({ export const s_PostValidationOptionalBody200Response = z.object({ id: z.string().optional(), }) + +export const s_GetResponsesDefault200Response = z.object({ + id: z.string().optional(), +}) + +export const s_GetResponsesDefaultdefaultResponse = z.object({ + error: z.string().optional(), +}) diff --git a/e2e/src/generated/server/koa/models.ts b/e2e/src/generated/server/koa/models.ts index d68fc9c4d..ec2870815 100644 --- a/e2e/src/generated/server/koa/models.ts +++ b/e2e/src/generated/server/koa/models.ts @@ -95,6 +95,18 @@ export type t_GetParamsUnexplodedObjectQueryQuerySchema = { } } +export type t_GetResponsesDefault200Response = { + id?: string | undefined +} + +export type t_GetResponsesDefaultQuerySchema = { + status?: ("200" | "500") | undefined +} + +export type t_GetResponsesDefaultdefaultResponse = { + error?: string | undefined +} + export type t_GetValidationNumbersRandomNumberQuerySchema = { forbidden?: number[] | undefined max?: number | undefined diff --git a/e2e/src/generated/server/koa/routes/validation.ts b/e2e/src/generated/server/koa/routes/validation.ts index 670de02cf..6eace0ac9 100644 --- a/e2e/src/generated/server/koa/routes/validation.ts +++ b/e2e/src/generated/server/koa/routes/validation.ts @@ -22,6 +22,9 @@ import type {Next} from "koa" import {z} from "zod/v4" import type { t_Enumerations, + t_GetResponsesDefault200Response, + t_GetResponsesDefaultdefaultResponse, + t_GetResponsesDefaultQuerySchema, t_GetValidationNumbersRandomNumberQuerySchema, t_PostValidationOptionalBody200Response, t_PostValidationOptionalBodyRequestBody, @@ -29,6 +32,8 @@ import type { } from "../models.ts" import { s_Enumerations, + s_GetResponsesDefault200Response, + s_GetResponsesDefaultdefaultResponse, s_PostValidationOptionalBody200Response, s_PostValidationOptionalBodyRequestBody, s_RandomNumber, @@ -98,6 +103,25 @@ export type GetResponses500 = ( next: Next, ) => Promise | Res<500, void> | typeof SkipResponse> +export type GetResponsesDefaultResponder = { + with200(): KoaRuntimeResponse + withDefault( + status: StatusCode, + ): KoaRuntimeResponse +} & KoaRuntimeResponder + +export type GetResponsesDefault = ( + params: Params, + respond: GetResponsesDefaultResponder, + ctx: RouterContext, + next: Next, +) => Promise< + | KoaRuntimeResponse + | Res<200, t_GetResponsesDefault200Response> + | Res + | typeof SkipResponse +> + export type GetResponsesEmptyResponder = { with204(): KoaRuntimeResponse } & KoaRuntimeResponder @@ -114,6 +138,7 @@ export type ValidationImplementation = { postValidationEnums: PostValidationEnums postValidationOptionalBody: PostValidationOptionalBody getResponses500: GetResponses500 + getResponsesDefault: GetResponsesDefault getResponsesEmpty: GetResponsesEmpty } @@ -283,6 +308,47 @@ export function createValidationRouter( .then(handleResponse(ctx, next, getResponses500ResponseValidator)) }) + const getResponsesDefaultQuerySchema = z.object({ + status: z.enum(["200", "500"]).optional(), + }) + + const getResponsesDefaultResponseValidator = responseValidationFactory( + [["200", s_GetResponsesDefault200Response]], + s_GetResponsesDefaultdefaultResponse, + ) + + router.get("getResponsesDefault", "/responses/default", async (ctx, next) => { + const input = { + params: undefined, + query: parseRequestInput( + getResponsesDefaultQuerySchema, + ctx.query, + RequestInputType.QueryString, + ), + body: undefined, + headers: undefined, + } + + const responder = { + with200() { + return new KoaRuntimeResponse(200) + }, + withDefault(status: StatusCode) { + return new KoaRuntimeResponse( + status, + ) + }, + withStatus(status: StatusCode) { + return new KoaRuntimeResponse(status) + }, + } + + await implementation + .getResponsesDefault(input, responder, ctx, next) + .catch(handleImplementationError) + .then(handleResponse(ctx, next, getResponsesDefaultResponseValidator)) + }) + const getResponsesEmptyResponseValidator = responseValidationFactory( [["204", z.undefined()]], undefined, diff --git a/e2e/src/generated/server/koa/schemas.ts b/e2e/src/generated/server/koa/schemas.ts index 69f49cba8..e7fda34aa 100644 --- a/e2e/src/generated/server/koa/schemas.ts +++ b/e2e/src/generated/server/koa/schemas.ts @@ -72,3 +72,11 @@ export const s_PostValidationOptionalBodyRequestBody = z.object({ export const s_PostValidationOptionalBody200Response = z.object({ id: z.string().optional(), }) + +export const s_GetResponsesDefault200Response = z.object({ + id: z.string().optional(), +}) + +export const s_GetResponsesDefaultdefaultResponse = z.object({ + error: z.string().optional(), +}) diff --git a/e2e/src/index.axios.spec.ts b/e2e/src/index.axios.spec.ts index 3e84e0bab..4f63e4d64 100644 --- a/e2e/src/index.axios.spec.ts +++ b/e2e/src/index.axios.spec.ts @@ -372,6 +372,29 @@ describe.each( }) }) + describe("GET /responses/default", () => { + it("returns {id: string} for 200", async () => { + const {status, data} = await client.getResponsesDefault({status: "200"}) + + expect(status).toBe(200) + expect(data.id).toBe("123") + }) + + it("returns {error: string} for other status codes", async () => { + const err = await client.getResponsesDefault({status: "500"}).then( + () => { + throw new Error("expected request to fail") + }, + (err: AxiosError) => err, + ) + + expect(err.status).toBe(500) + expect(err.response?.data).toStrictEqual({ + error: "something went wrong", + }) + }) + }) + describe("GET /responses/500", () => { it("returns response from error middleware", async () => { const err = await client.getResponses500().then( diff --git a/e2e/src/index.fetch.spec.ts b/e2e/src/index.fetch.spec.ts index ed52b44c1..db516eca2 100644 --- a/e2e/src/index.fetch.spec.ts +++ b/e2e/src/index.fetch.spec.ts @@ -379,6 +379,32 @@ describe.each( }) }) + describe("GET /responses/default", () => { + it("returns {id: string} for 200", async () => { + const res = await client.getResponsesDefault({status: "200"}) + + expect(res.status).toBe(200) + + if (res.status === 200) { + const body = await res.json() + + expect(body.id).toBe("123") + } + }) + + it("returns {error: string} for other status codes", async () => { + const res = await client.getResponsesDefault({status: "500"}) + + expect(res.status).toBe(500) + + if (res.status !== 200) { + const body = await res.json() + + expect(body.error).toBe("something went wrong") + } + }) + }) + describe("GET /responses/500", () => { it("returns response from error middleware", async () => { const res = await client.getResponses500() diff --git a/e2e/src/routes/express/validation.ts b/e2e/src/routes/express/validation.ts index 70c0c25df..06081fd04 100644 --- a/e2e/src/routes/express/validation.ts +++ b/e2e/src/routes/express/validation.ts @@ -1,6 +1,7 @@ import { createRouter, type GetResponses500, + type GetResponsesDefault, type GetResponsesEmpty, type GetValidationNumbersRandomNumber, type PostValidationEnums, @@ -52,6 +53,17 @@ const getResponsesEmpty: GetResponsesEmpty = async (_, respond) => { return respond.with204() } +const getResponsesDefault: GetResponsesDefault = async ({query}, respond) => { + const status = query.status ?? "200" + if (status === "200") { + return respond.with200().body({id: "123"}) + } else if (status === "500") { + return respond.withDefault(500).body({error: "something went wrong"}) + } + + throw new Error("unreachable") +} + const getResponses500: GetResponses500 = async () => { throw new Error("something went wrong") } @@ -62,6 +74,7 @@ export function createValidationRouter() { postValidationOptionalBody, getValidationNumbersRandomNumber, getResponsesEmpty, + getResponsesDefault, getResponses500, }) } diff --git a/e2e/src/routes/koa/validation.ts b/e2e/src/routes/koa/validation.ts index 75fc94784..da34d63b5 100644 --- a/e2e/src/routes/koa/validation.ts +++ b/e2e/src/routes/koa/validation.ts @@ -1,6 +1,7 @@ import { createRouter, type GetResponses500, + type GetResponsesDefault, type GetResponsesEmpty, type GetValidationNumbersRandomNumber, type PostValidationEnums, @@ -54,6 +55,17 @@ const getResponsesEmpty: GetResponsesEmpty = async (_, respond, ctx) => { return respond.with204() } +const getResponsesDefault: GetResponsesDefault = async ({query}, respond) => { + const status = query.status ?? "200" + if (status === "200") { + return respond.with200().body({id: "123"}) + } else if (status === "500") { + return respond.withDefault(500).body({error: "something went wrong"}) + } + + throw new Error("unreachable") +} + const getResponses500: GetResponses500 = async () => { throw new Error("something went wrong") } @@ -64,6 +76,7 @@ export function createValidationRouter() { postValidationOptionalBody, getValidationNumbersRandomNumber, getResponsesEmpty, + getResponsesDefault, getResponses500, }) }