fix(fetch): accept a named interface as request body without a cast
FetchOptions['body'] listed `Record<string, unknown>`, which a named interface is not assignable to (interfaces carry no implicit index signature), so every caller passing a typed body had to cast it. Widen the object member to `object`: named interfaces and arrays now assign directly, `any` is not introduced, and bare primitives (number/boolean) are still rejected. Runtime is unchanged — the serializer already narrows the body with its own casts. Guard it with a type test (src/types.test-d.ts, run under vitest --typecheck): a named interface must assign to the body type and flow through the method shortcuts with no cast, while bare primitives stay rejected. Enable typecheck for the package's vitest project so the assertions are enforced by tsc. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, unknown>` body type would reject it and
|
||||
// force a cast at every call site. It must assign directly.
|
||||
interface CreateUser { name: string; age: number }
|
||||
expectTypeOf<CreateUser>().toExtend<Body>();
|
||||
assertType<Body>({ name: 'Alice', age: 30 } satisfies CreateUser);
|
||||
});
|
||||
|
||||
it('accepts plain objects, arrays, BodyInit strings and null', () => {
|
||||
assertType<Body>({ a: 1 });
|
||||
assertType<Body>([1, 2, 3]);
|
||||
assertType<Body>('raw string');
|
||||
assertType<Body>(new FormData());
|
||||
assertType<Body>(null);
|
||||
});
|
||||
|
||||
it('rejects bare primitives', () => {
|
||||
// @ts-expect-error a bare number is not a valid request body
|
||||
assertType<Body>(42);
|
||||
// @ts-expect-error a bare boolean is not a valid request body
|
||||
assertType<Body>(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 });
|
||||
});
|
||||
});
|
||||
@@ -79,8 +79,14 @@ export interface FetchOptions<R extends ResponseType = 'json', T = unknown>
|
||||
FetchHooks<T, R> {
|
||||
/** Base URL prepended to all relative request URLs */
|
||||
baseURL?: string;
|
||||
/** Request body — plain objects are automatically JSON-serialized */
|
||||
body?: RequestInit['body'] | Record<string, unknown> | 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<string, unknown>` 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 */
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user