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
2 changes: 2 additions & 0 deletions docs/guide/typed-fetch.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/api/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ function fetch<P extends keyof RouteMap, M extends MethodsOf<P>>(
| `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}
Expand Down
10 changes: 7 additions & 3 deletions packages/void/src/runtime/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export { FetchError };

import type { RouteMap } from 'void/routes';
export type MethodsOf<P extends keyof RouteMap> = keyof RouteMap[P] & string;
type DefaultMethod<P extends keyof RouteMap> = 'GET' extends MethodsOf<P> ? 'GET' : never;
type DefaultMethod<P extends keyof RouteMap> = 'GET' extends MethodsOf<P> ? 'GET' : MethodsOf<P>;

type RouteEntry<P extends keyof RouteMap, M extends MethodsOf<P>> = RouteMap[P][M] extends {
input: infer I;
Expand Down Expand Up @@ -47,7 +47,7 @@ type FetchOptions<P extends keyof RouteMap, M extends MethodsOf<P>> = BodyOption
baseURL?: string;
retry?: number;
timeout?: number;
};
} & ('GET' extends M ? unknown : { method: M });

export type UntypedFetchOptions = {
method?: string;
Expand Down Expand Up @@ -77,7 +77,11 @@ const browserFetch = createRouteFetch((...args) => globalThis.fetch(...args));
* ```
*/
export function fetch<P extends keyof RouteMap & string>(
path: HasRequiredInput<P, DefaultMethod<P>> extends false ? P : never,
path: 'GET' extends MethodsOf<P>
? HasRequiredInput<P, DefaultMethod<P>> extends false
? P
: never
: never,
): Promise<OutputOf<P, DefaultMethod<P>>>;
export function fetch<P extends keyof RouteMap & string, M extends MethodsOf<P> = DefaultMethod<P>>(
path: P,
Expand Down
27 changes: 27 additions & 0 deletions packages/void/test/unit/fetch.typecheck.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand Down