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..27ab4a90 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,7 +12,62 @@ export interface $Fetch { options?: FetchOptions ): Promise>>; native: Fetch; - create(defaults: FetchOptions, globalOptions?: CreateFetchOptions): $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; + +// 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 + ? CaseInsensitiveKey extends infer ActualKey + ? ActualKey extends keyof S[P] + ? S[P][ActualKey] + : never + : 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 + ): TypedFetch; } // -------------------------- diff --git a/test/typed-create.test-d.ts b/test/typed-create.test-d.ts new file mode 100644 index 00000000..4a07f56f --- /dev/null +++ b/test/typed-create.test-d.ts @@ -0,0 +1,134 @@ +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>(); + }); + + 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>(); + }); +});