diff --git a/core/fetch/src/types.test-d.ts b/core/fetch/src/types.test-d.ts new file mode 100644 index 0000000..ba6df2f --- /dev/null +++ b/core/fetch/src/types.test-d.ts @@ -0,0 +1,40 @@ +import { assertType, describe, expectTypeOf, it } from 'vitest'; + +import type { FetchOptions } from './types'; +import { createFetch } from './fetch'; + +type Body = FetchOptions['body']; + +describe('FetchOptions body', () => { + it('accepts a named interface without a cast', () => { + // The regression under test: a named `interface` has no implicit index + // signature, so a `Record` body type would reject it and + // force a cast at every call site. It must assign directly. + interface CreateUser { name: string; age: number } + expectTypeOf().toExtend(); + assertType({ name: 'Alice', age: 30 } satisfies CreateUser); + }); + + it('accepts plain objects, arrays, BodyInit strings and null', () => { + assertType({ a: 1 }); + assertType([1, 2, 3]); + assertType('raw string'); + assertType(new FormData()); + assertType(null); + }); + + it('rejects bare primitives', () => { + // @ts-expect-error a bare number is not a valid request body + assertType(42); + // @ts-expect-error a bare boolean is not a valid request body + assertType(true); + }); + + it('lets a typed interface flow through the method shortcuts', () => { + interface CreateDeal { title: string; amount: number } + const $fetch = createFetch(); + const deal: CreateDeal = { title: 'x', amount: 1 }; + // Must type-check with no cast on the body. + expectTypeOf($fetch.post).toBeCallableWith('/deals', { body: deal }); + }); +}); diff --git a/core/fetch/src/types.ts b/core/fetch/src/types.ts index 9219240..e01a276 100644 --- a/core/fetch/src/types.ts +++ b/core/fetch/src/types.ts @@ -79,8 +79,14 @@ export interface FetchOptions FetchHooks { /** Base URL prepended to all relative request URLs */ baseURL?: string; - /** Request body — plain objects are automatically JSON-serialized */ - body?: RequestInit['body'] | Record | unknown[] | null; + /** + * Request body. `BodyInit` values (string, Blob, FormData, streams, …) are + * sent as-is; any other object or array is JSON-serialized. Typed as `object` + * rather than `Record` so a named `interface` assigns + * directly — interfaces carry no implicit index signature and would otherwise + * force every caller to cast the body. + */ + body?: RequestInit['body'] | object | null; /** Suppress throwing on 4xx/5xx responses */ ignoreResponseError?: boolean; /** URL query parameters serialized and appended to the request URL */ diff --git a/core/fetch/vitest.config.ts b/core/fetch/vitest.config.ts index 4ac6027..0ee6305 100644 --- a/core/fetch/vitest.config.ts +++ b/core/fetch/vitest.config.ts @@ -3,5 +3,11 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', + // Type tests (*.test-d.ts) are statically analyzed with `tsc --noEmit` + // alongside the runtime suite. + typecheck: { + enabled: true, + tsconfig: './tsconfig.src.json', + }, }, });