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>>; export function fetch

= 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');