From 99d1cfa31dbe087bfa4c39ffff3a76f5606c4888 Mon Sep 17 00:00:00 2001 From: productdevbook Date: Mon, 30 Mar 2026 07:55:44 +0300 Subject: [PATCH 1/2] feat(types): add schema-typed $fetch.create with OpenAPI path inference Add generic parameter to `$fetch.create()` that enables type-safe path and method inference from OpenAPI-style schema types (e.g., from openapi-typescript). Usage: const api = $fetch.create({ baseURL: '...' }) const users = await api('/users') // typed response const user = await api('/users/{id}', { method: 'GET' }) Resolves #406 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/fetch.ts | 6 +-- src/types.ts | 43 +++++++++++++++++ test/typed-create.test-d.ts | 94 +++++++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 test/typed-create.test-d.ts diff --git a/src/fetch.ts b/src/fetch.ts index 10c91583..ecc4d021 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -265,16 +265,16 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch { $fetch.native = (...args) => fetch(...args); - $fetch.create = (defaultOptions = {}, customGlobalOptions = {}) => + $fetch.create = ((defaultOptions = {}, customGlobalOptions = {}) => createFetch({ ...globalOptions, ...customGlobalOptions, defaults: { ...globalOptions.defaults, - ...customGlobalOptions.defaults, + ...(customGlobalOptions as CreateFetchOptions).defaults, ...defaultOptions, }, - }); + })) as $Fetch["create"]; return $fetch; } diff --git a/src/types.ts b/src/types.ts index 66a84faf..9aca9b51 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,6 +11,49 @@ export interface $Fetch { request: FetchRequest, options?: FetchOptions ): Promise>>; + native: Fetch; + create( + defaults: FetchOptions, + globalOptions?: CreateFetchOptions + ): [S] extends [never] ? $Fetch : TypedFetch; +} + +// -------------------------- +// Typed Fetch (OpenAPI support) +// -------------------------- + +type UpperMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD"; +type LowerMethod = Lowercase; +type AnyMethod = UpperMethod | LowerMethod; + +type ExtractMethod = + S[P] extends Record + ? Lowercase extends Lowercase + ? S[P][Lowercase & keyof S[P]] + : never + : never; + +type ResponseBody = Op extends { + responses: { 200: { content: { "application/json": infer R } } }; +} + ? R + : Op extends { responses: { 200: { schema: infer R } } } + ? R + : Op extends { response: infer R } + ? R + : unknown; + +export interface TypedFetch { +

( + request: P, + options?: FetchOptions & { method?: M } + ): Promise>>; + + raw

( + request: P, + options?: FetchOptions & { method?: M } + ): Promise>>>; + native: Fetch; create(defaults: FetchOptions, globalOptions?: CreateFetchOptions): $Fetch; } diff --git a/test/typed-create.test-d.ts b/test/typed-create.test-d.ts new file mode 100644 index 00000000..9ffcd10e --- /dev/null +++ b/test/typed-create.test-d.ts @@ -0,0 +1,94 @@ +import { describe, expectTypeOf, it } from "vitest"; +import { createFetch } from "../src/fetch.ts"; +import type { $Fetch, TypedFetch } from "../src/types.ts"; + +// Mock OpenAPI-style schema (similar to openapi-typescript output) +interface ApiPaths { + "/users": { + get: { + responses: { + 200: { + content: { "application/json": { id: number; name: string }[] }; + }; + }; + }; + post: { + requestBody: { + content: { "application/json": { name: string } }; + }; + responses: { + 200: { + content: { "application/json": { id: number; name: string } }; + }; + }; + }; + }; + "/users/{id}": { + get: { + responses: { + 200: { content: { "application/json": { id: number; name: string } } }; + }; + }; + delete: { + responses: { + 200: { content: { "application/json": { success: boolean } } }; + }; + }; + }; +} + +describe("typed create", () => { + it("$fetch.create returns $Fetch without generics", () => { + const $fetch = createFetch(); + const api = $fetch.create({}); + expectTypeOf(api).toMatchTypeOf<$Fetch>(); + }); + + it("$fetch.create returns TypedFetch", () => { + const $fetch = createFetch(); + const api = $fetch.create({}); + expectTypeOf(api).toMatchTypeOf>(); + }); + + it("infers GET response type from schema", () => { + const $fetch = createFetch(); + const api = $fetch.create({}); + + const result = api("/users"); + expectTypeOf(result).toEqualTypeOf< + Promise<{ id: number; name: string }[]> + >(); + }); + + it("infers GET response with explicit method", () => { + const $fetch = createFetch(); + const api = $fetch.create({}); + + const result = api("/users/{id}", { method: "GET" }); + expectTypeOf(result).toEqualTypeOf>(); + }); + + it("infers POST response type", () => { + const $fetch = createFetch(); + const api = $fetch.create({}); + + const result = api("/users", { method: "POST" }); + expectTypeOf(result).toEqualTypeOf>(); + }); + + it("infers DELETE response type", () => { + const $fetch = createFetch(); + const api = $fetch.create({}); + + const result = api("/users/{id}", { method: "DELETE" }); + expectTypeOf(result).toEqualTypeOf>(); + }); + + it("accepts lowercase methods", () => { + const $fetch = createFetch(); + const api = $fetch.create({}); + + const result = api("/users", { method: "post" }); + expectTypeOf(result).toEqualTypeOf>(); + }); +}); From 8e734baee8eed0ddf0081bb6d7465d5112802df0 Mon Sep 17 00:00:00 2001 From: productdevbook Date: Mon, 30 Mar 2026 10:30:23 +0300 Subject: [PATCH 2/2] fix(types): case-insensitive method keys, preserve schema on create, negative tests - Fix ExtractMethod to work with both upper-case and lower-case schema method keys via CaseInsensitiveKey helper type - TypedFetch.create() now returns TypedFetch (preserves schema) - Add @ts-expect-error test for non-schema paths - Add test for TypedFetch.create() chaining - Add test for upper-case schema keys (GET, POST) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/types.ts | 18 ++++++++++++++--- test/typed-create.test-d.ts | 40 +++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/types.ts b/src/types.ts index 9aca9b51..27ab4a90 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,10 +26,19 @@ type UpperMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD"; type LowerMethod = Lowercase; type AnyMethod = UpperMethod | LowerMethod; +// Find the actual key in Obj whose lowercase matches Lowercase +type CaseInsensitiveKey = { + [Key in string & keyof Obj]: Lowercase extends Lowercase + ? Key + : never; +}[string & keyof Obj]; + type ExtractMethod = S[P] extends Record - ? Lowercase extends Lowercase - ? S[P][Lowercase & keyof S[P]] + ? CaseInsensitiveKey extends infer ActualKey + ? ActualKey extends keyof S[P] + ? S[P][ActualKey] + : never : never : never; @@ -55,7 +64,10 @@ export interface TypedFetch { ): Promise>>>; native: Fetch; - create(defaults: FetchOptions, globalOptions?: CreateFetchOptions): $Fetch; + create( + defaults: FetchOptions, + globalOptions?: CreateFetchOptions + ): TypedFetch; } // -------------------------- diff --git a/test/typed-create.test-d.ts b/test/typed-create.test-d.ts index 9ffcd10e..4a07f56f 100644 --- a/test/typed-create.test-d.ts +++ b/test/typed-create.test-d.ts @@ -91,4 +91,44 @@ describe("typed create", () => { const result = api("/users", { method: "post" }); expectTypeOf(result).toEqualTypeOf>(); }); + + it("rejects paths not in schema", () => { + const $fetch = createFetch(); + const api = $fetch.create({}); + + // @ts-expect-error — path not in schema + api("/nonexistent"); + }); + + it("TypedFetch.create preserves schema type", () => { + const $fetch = createFetch(); + const api = $fetch.create({}); + const api2 = api.create({ headers: { Authorization: "Bearer ..." } }); + + // api2 should still be TypedFetch + const result = api2("/users"); + expectTypeOf(result).toEqualTypeOf< + Promise<{ id: number; name: string }[]> + >(); + }); + + it("works with upper-case schema keys", () => { + interface UpperCasePaths { + "/health": { + GET: { + responses: { + 200: { content: { "application/json": { status: string } } }; + }; + }; + }; + } + const $fetch = createFetch(); + const api = $fetch.create({}); + + const result = api("/health"); + expectTypeOf(result).toEqualTypeOf>(); + + const result2 = api("/health", { method: "GET" }); + expectTypeOf(result2).toEqualTypeOf>(); + }); });