From 1cab48ca48df69baa11b8548fc10125d7b48f0e0 Mon Sep 17 00:00:00 2001 From: robonen Date: Fri, 17 Jul 2026 21:59:09 +0700 Subject: [PATCH] fix(fetch): accept a named interface as request body without a cast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FetchOptions['body'] listed `Record`, 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 --- core/fetch/src/types.test-d.ts | 40 ++++++++++++++++++++++++++++++++++ core/fetch/src/types.ts | 10 +++++++-- core/fetch/vitest.config.ts | 6 +++++ 3 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 core/fetch/src/types.test-d.ts 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', + }, }, });