diff --git a/docs/guide/typed-fetch.md b/docs/guide/typed-fetch.md index 5dfa20994..95b716bf8 100644 --- a/docs/guide/typed-fetch.md +++ b/docs/guide/typed-fetch.md @@ -35,6 +35,8 @@ The client constrains every part of the request: - **Body, query, params:** when your handler uses [`defineHandler.withValidator()`](./server-routing.md#validation), validator schemas become the input types and the client enforces them at the call site. - **Response type:** the return type matches what your handler returns after [JSON serialization](./type-safety.md#serialization). +Omitting `method` sends GET. For a route without a GET handler, specify a supported method, such as `{ method: 'POST' }`. + ## Query Parameters Pass `query` for GET requests with search parameters: diff --git a/docs/reference/api/client.md b/docs/reference/api/client.md index 4f495c399..eec2fd26f 100644 --- a/docs/reference/api/client.md +++ b/docs/reference/api/client.md @@ -43,6 +43,8 @@ function fetch
>( | `retry` | `number` | Number of retry attempts (ofetch default: 1 for GET). | | `timeout` | `number` | Request timeout in milliseconds. | +Omitting `method` sends GET. Non-GET calls require an explicit `method`, including calls that specify the method as a generic type argument. + Returns the parsed JSON response body, or `undefined` for 204 responses. Throws `FetchError` on non-2xx responses. ## `FetchError` {#fetcherror} diff --git a/packages/void/src/runtime/fetch.ts b/packages/void/src/runtime/fetch.ts index be4c5489e..a80a1d7c3 100644 --- a/packages/void/src/runtime/fetch.ts +++ b/packages/void/src/runtime/fetch.ts @@ -5,7 +5,7 @@ export { FetchError }; import type { RouteMap } from 'void/routes'; export type MethodsOf
= keyof RouteMap[P] & string; -type DefaultMethod
= 'GET' extends MethodsOf
? 'GET' : never; +type DefaultMethod
= 'GET' extends MethodsOf
? 'GET' : MethodsOf
; type RouteEntry
> = RouteMap[P][M] extends { input: infer I; @@ -47,7 +47,7 @@ type FetchOptions
> = BodyOption baseURL?: string; retry?: number; timeout?: number; - }; + } & ('GET' extends M ? unknown : { method: M }); export type UntypedFetchOptions = { method?: string; @@ -77,7 +77,11 @@ const browserFetch = createRouteFetch((...args) => globalThis.fetch(...args)); * ``` */ export function fetch
( - path: HasRequiredInput
> extends false ? P : never, + path: 'GET' extends MethodsOf
+ ? HasRequiredInput
> extends false
+ ? P
+ : never
+ : never,
): Promise = DefaultMethod >(
path: P,
diff --git a/packages/void/test/unit/fetch.typecheck.ts b/packages/void/test/unit/fetch.typecheck.ts
index c4b4d0d30..41a7536ac 100644
--- a/packages/void/test/unit/fetch.typecheck.ts
+++ b/packages/void/test/unit/fetch.typecheck.ts
@@ -1,7 +1,34 @@
import { describe, it } from 'vite-plus/test';
import { fetch as typedFetch } from '../../src/runtime/fetch.ts';
+declare module 'void/routes' {
+ interface RouteMap {
+ '/api/rebuild': {
+ POST: { input: {}; output: { started: boolean } };
+ };
+ }
+}
+
describe('typed fetch client', () => {
+ it('type: requires an explicit method whenever the request cannot default to GET', () => {
+ const _check = async () => {
+ const result = await typedFetch('/api/rebuild', { method: 'POST' });
+ const started: boolean = result.started;
+ void started;
+ await typedFetch('/api/hello', {});
+
+ // @ts-expect-error - a POST-only route cannot be called with the default GET
+ await typedFetch('/api/rebuild');
+ // @ts-expect-error - an options object does not supply a runtime method
+ await typedFetch('/api/rebuild', {});
+ // @ts-expect-error - generic type arguments do not set the runtime method
+ await typedFetch<'/api/rebuild', 'POST'>('/api/rebuild', {});
+ // @ts-expect-error - selecting POST requires a method even when GET also exists
+ await typedFetch<'/api/users', 'POST'>('/api/users', { body: { name: 'Alice' } });
+ };
+ void _check;
+ });
+
it('type: output is correctly narrowed per route', () => {
const _check = async () => {
const hello = await typedFetch('/api/hello');