Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
57 changes: 56 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,62 @@ export interface $Fetch {
options?: FetchOptions<R>
): Promise<FetchResponse<MappedResponseType<R, T>>>;
native: Fetch;
create(defaults: FetchOptions, globalOptions?: CreateFetchOptions): $Fetch;
create<S = never>(
defaults: FetchOptions,
globalOptions?: CreateFetchOptions
): [S] extends [never] ? $Fetch : TypedFetch<S>;
}

// --------------------------
// Typed Fetch (OpenAPI support)
// --------------------------

type UpperMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD";
type LowerMethod = Lowercase<UpperMethod>;
type AnyMethod = UpperMethod | LowerMethod;

// Find the actual key in Obj whose lowercase matches Lowercase<K>
type CaseInsensitiveKey<Obj, K extends string> = {
[Key in string & keyof Obj]: Lowercase<Key> extends Lowercase<K>
? Key
: never;
}[string & keyof Obj];

type ExtractMethod<S, P extends keyof S, M extends string> =
S[P] extends Record<string, any>
? CaseInsensitiveKey<S[P], M> extends infer ActualKey
? ActualKey extends keyof S[P]
? S[P][ActualKey]
: never
: never
: never;

type ResponseBody<Op> = 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<S> {
<P extends string & keyof S, M extends string & AnyMethod = "GET">(
request: P,
options?: FetchOptions & { method?: M }
): Promise<ResponseBody<ExtractMethod<S, P, M>>>;

raw<P extends string & keyof S, M extends string & AnyMethod = "GET">(
request: P,
options?: FetchOptions & { method?: M }
): Promise<FetchResponse<ResponseBody<ExtractMethod<S, P, M>>>>;

native: Fetch;
create(
defaults: FetchOptions,
globalOptions?: CreateFetchOptions
): TypedFetch<S>;
}

// --------------------------
Expand Down
134 changes: 134 additions & 0 deletions test/typed-create.test-d.ts
Original file line number Diff line number Diff line change
@@ -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<S> returns TypedFetch<S>", () => {
const $fetch = createFetch();
const api = $fetch.create<ApiPaths>({});
expectTypeOf(api).toMatchTypeOf<TypedFetch<ApiPaths>>();
});

it("infers GET response type from schema", () => {
const $fetch = createFetch();
const api = $fetch.create<ApiPaths>({});

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<ApiPaths>({});

const result = api("/users/{id}", { method: "GET" });
expectTypeOf(result).toEqualTypeOf<Promise<{ id: number; name: string }>>();
});

it("infers POST response type", () => {
const $fetch = createFetch();
const api = $fetch.create<ApiPaths>({});

const result = api("/users", { method: "POST" });
expectTypeOf(result).toEqualTypeOf<Promise<{ id: number; name: string }>>();
});

it("infers DELETE response type", () => {
const $fetch = createFetch();
const api = $fetch.create<ApiPaths>({});

const result = api("/users/{id}", { method: "DELETE" });
expectTypeOf(result).toEqualTypeOf<Promise<{ success: boolean }>>();
});

it("accepts lowercase methods", () => {
const $fetch = createFetch();
const api = $fetch.create<ApiPaths>({});

const result = api("/users", { method: "post" });
expectTypeOf(result).toEqualTypeOf<Promise<{ id: number; name: string }>>();
});

it("rejects paths not in schema", () => {
const $fetch = createFetch();
const api = $fetch.create<ApiPaths>({});

// @ts-expect-error — path not in schema
api("/nonexistent");
});

it("TypedFetch.create preserves schema type", () => {
const $fetch = createFetch();
const api = $fetch.create<ApiPaths>({});
const api2 = api.create({ headers: { Authorization: "Bearer ..." } });

// api2 should still be TypedFetch<ApiPaths>
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<UpperCasePaths>({});

const result = api("/health");
expectTypeOf(result).toEqualTypeOf<Promise<{ status: string }>>();

const result2 = api("/health", { method: "GET" });
expectTypeOf(result2).toEqualTypeOf<Promise<{ status: string }>>();
});
});