Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/chatty-bears-clap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"fetchify": major
---

**BREAKING**: `createFetchify` client methods (`get`/`post`/`put`/`patch`/`delete`/`head`/`options`) now resolve to a `FetchifyResponse<T>` object (`{ data: T | null; response: Response }`) instead of the raw `fetch` `Response`. `data` holds the parsed response body — JSON-parsed when possible, falling back to raw text, and `null` when the response wasn't `ok` — and each method accepts an optional generic (e.g. `client.get<User>("/user")`) to type `data`. `response` is a clone of the original `fetch` `Response`, left unread for callers who need `.json()`/`.blob()`/`status`/`headers` directly. Call sites using the old `const response = await client.get(...)` pattern need to switch to `const { data, response } = await client.get(...)`.
38 changes: 34 additions & 4 deletions DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,17 @@ interface Fetchify {
}
```

Each method has the [`FetchifyMethod`](#fetchifymethod) signature: it takes a path and an optional `RequestInit`, and returns a `Promise<Response>`.
Each method has the [`FetchifyMethod`](#fetchifymethod) signature: it takes a path and an optional `RequestInit`, plus an optional type parameter `T`, and returns a `Promise<`[`FetchifyResponse<T>`](#fetchifyresponset)`>`.

If `options.baseUrl` is set, request paths are resolved against it; otherwise the path is passed straight through to `fetch`.

```ts
const client = createFetchify({ baseUrl: "https://api.example.com" });

const res = await client.get("/users/1");
const { data, response } = await client.get<User>("/users/1");
// data: User | null — JSON-parsed body, or null if the response wasn't ok
// response: Response — unread clone of the raw fetch Response

const created = await client.post("/users", {
body: JSON.stringify({ name: "Ada" }),
});
Expand Down Expand Up @@ -82,10 +85,37 @@ Configuration accepted by `createFetchify` and `defineOptions`.
### `FetchifyMethod`

```ts
type FetchifyMethod = (path: string, init?: RequestInit) => Promise<Response>;
type FetchifyMethod = <T = string>(
path: string,
init?: RequestInit,
) => Promise<FetchifyResponse<T>>;
```

Signature of each HTTP verb method on a `Fetchify` client. The type parameter `T` types the resolved [`FetchifyResponse`](#fetchifyresponset)'s `data` field and defaults to `string`.

### `FetchifyResponse<T>`

```ts
interface FetchifyResponse<T = string> {
data: T | null;
response: Response;
}
```

Signature of each HTTP verb method on a `Fetchify` client.
Resolved value of every `Fetchify` client method.

- `data` — the response body, only populated when the underlying response was `ok`. The body is read as text and `JSON.parse`d: the parsed value is used if that succeeds, otherwise the raw text is used. `null` when the response wasn't `ok`, or the body couldn't be read.
- `response` — a clone of the original `fetch` `Response`, taken before `data` is read, so its body is still unread. Use it for `.json()`, `.blob()`, `status`, `headers`, `ok`, etc.

```ts
const { data, response } = await client.get<User>("/users/1");

if (!response.ok) {
console.error(response.status);
} else {
console.log(data); // User | null
}
```

## `VERSION`

Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/custom-response-object/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-02
41 changes: 41 additions & 0 deletions openspec/changes/custom-response-object/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
## Context

`createFetchify`'s client methods currently resolve straight to the `Response` produced by the underlying `fetch` call (`src/lib/create-fetchify.ts`, `src/types/fetchify-method.ts`). Callers must check `response.ok`, read the body themselves, and cast the parsed result to whatever shape they expect — and since a `Response` body stream can only be consumed once, anyone who wants both a parsed body and access to the raw response has to remember to `.clone()` it before reading. This change moves that bookkeeping into `createFetchify` itself by wrapping every method's result in a small, generic `FetchifyResponse<T>` object.

## Goals / Non-Goals

**Goals:**
- Give callers the response body already parsed, for the common case of a successful (`ok`) response, without a separate `.text()`/`.json()` call.
- Let callers declare the expected body shape per call (`client.get<User>("/user")`) and get that type back on `data`, instead of hand-writing a cast at every call site.
- Preserve full access to the original `Response` (status, headers, `ok`, and its own body-reading methods) via an unread clone, so nothing the caller could do with a raw `fetch` result is lost.
- Apply uniformly to every HTTP method on the client (`get`/`post`/`put`/`patch`/`delete`/`head`/`options`).

**Non-Goals:**
- No runtime validation/parsing of `data` against `T` — the generic is a compile-time assertion the caller opts into (same trust model as most typed HTTP clients); `createFetchify` does not check that the JSON it parsed actually matches the shape `T` describes.
- No content-type sniffing — the parse strategy (try JSON, fall back to text) is fixed and does not branch on response headers.
- No throwing on non-`ok` responses — a non-`ok` response still resolves normally, just with `data: null`; callers inspect `response.ok`/`response.status` themselves.
- No retries, timeouts, or interceptors — out of scope, unchanged from the existing `http-client` capability's non-goals.

## Decisions

- **`FetchifyResponse<T = string>` shape: `{ data: T | null; response: Response }`** — two fields cover both "give me the body" and "let me use the real Response" without collapsing them into one lossy value. Defaulting `T` to `string` keeps today's no-generic call sites (`client.get("/user")`) behaving the same as before this change for plain-text endpoints, and type-checking without any changes.
- Alternative considered: default `T` to `unknown`, forcing every caller to either specify a generic or narrow `data` before use — rejected as a bigger ergonomics regression for existing/simple callers than the value it adds; `string` matches the original (pre-generic) behavior.
- **Parse strategy: read the body as text, then attempt `JSON.parse`; use the parsed value if it succeeds, otherwise fall back to the raw text** — this is what makes `client.get<User>("/user")` actually yield an object on `data` at runtime (not just a type-level cast of a string), while still supporting plain-text endpoints (`data` stays a string when the body isn't JSON) without any opt-in flag.
- Alternative considered: always call `response.json()` directly (throw/produce `null` on non-JSON bodies) — rejected because it would break the plain-text case described in the original ask (`data` as parsed text) for any endpoint that isn't JSON.
- Alternative considered: branch on the `Content-Type` header to decide JSON vs. text — rejected as an extra layer of "cleverness"/surface area (mismatched or missing content-types, charsets) for a "simple wrapper" package; attempt-JSON-then-fall-back-to-text is simpler and doesn't depend on servers setting correct headers.
- Note: because TypeScript generics are erased at runtime, `T` cannot itself decide whether to parse JSON or return text — the runtime behavior is the same regardless of what `T` a caller passes; `T` only changes the static type of the result. This is called out explicitly as a Non-Goal above (no runtime validation against `T`) so it isn't mistaken for a stronger guarantee.
- **`data` is `T | null`, populated only when `response.ok`** — a non-`ok` response (4xx/5xx) leaves `data: null` rather than attempting to parse an error body, so a failed request never crashes the caller's `await`; they inspect `response` to see what happened. A body-read/parse failure (rare — e.g. an aborted stream) also yields `data: null` rather than throwing.
- Alternative considered: populate `data` regardless of `ok`, or throw on non-`ok` — rejected: "when response is ok" was an explicit condition on `data`, and swallowing read/parse errors into `null` (vs. throwing) keeps `createFetchify` from introducing a new failure mode a caller must catch.
- **`response` is `raw.clone()`, taken before `data` is read** — cloning first guarantees the clone's body stream is completely untouched, regardless of whether the text/JSON read on the original succeeds, fails, or is skipped (non-`ok` case). The caller's `response` is always safe to read from scratch.
- Alternative considered: clone only when `ok` is true (skip cloning, and skip the body read, for non-`ok` responses) — rejected because `response` must behave the same way (a fresh, unread `Response`) regardless of `data`; making `response`'s "freshness" conditional on `ok` would be a surprising, hard-to-discover special case.
- **`FetchifyMethod` becomes a generic function type, `<T = string>(path, init?) => Promise<FetchifyResponse<T>>`, and each client method (`get`/`post`/etc.) forwards its own call-site type parameter** — matches the requested call pattern `client.get<User>("/user")` directly; the generic lives on the method call, not on `createFetchify` itself, since the expected shape varies per endpoint/call, not per client instance.
- Alternative considered: put the generic on `createFetchify<T>(...)` so the whole client is typed for one shape — rejected, a single client is used for many endpoints with different body shapes; per-call generics are the only sensible granularity.
- **Types live in `src/types/fetchify-response.ts` and `src/types/fetchify-method.ts`** — keeps `src/types/` as the home for shared structural types, mirroring the existing split between the function-shape type (`FetchifyMethod`) and the data-shape type (`FetchifyResponse`).
- **Breaking change, not additive** — `FetchifyMethod` changes in place (`Promise<Response>` → `Promise<FetchifyResponse<T>>`) rather than adding a second parallel method or an options flag to opt in. `createFetchify` is a "simple wrapper" per the original `http-client` design; keeping two return shapes around long-term would double the surface area for no lasting benefit. Ships as a `major` changeset per the project's semver conventions.

## Risks / Trade-offs

- [Every call site that destructures/uses the old `Response` return value now breaks at compile time] → Intentional and documented as a breaking change; TypeScript will surface every call site that needs updating (e.g. `const res = await client.get(...)` now needs `const { data, response } = await client.get(...)`), and the `major` changeset communicates it to consumers.
- [`client.get<User>(...)` doesn't actually verify the response matches `User`] → Accepted, documented as a Non-Goal; this matches the trust model of comparable typed HTTP clients (the generic is caller-asserted, not runtime-checked). Callers who need real validation layer their own schema check on top of `data`.
- [Attempt-JSON-then-fall-back-to-text means a response that happens to be valid JSON but semantically plain text (e.g. a body that's literally `"123"` or `"true"`) parses to a number/boolean instead of staying a string] → Accepted as inherent to any content-sniffing parse strategy without a content-type check; documented so it isn't a surprise, and callers with such endpoints can read `response.text()` themselves instead of relying on `data`.
- [Cloning on every request has a small overhead even when the caller never touches `response`] → Acceptable; `Response.clone()` is cheap (no body copy occurs until the clone's body is actually read) and this is the only way to guarantee `response` is always safe to consume.
24 changes: 24 additions & 0 deletions openspec/changes/custom-response-object/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
## Why

`createFetchify`'s HTTP methods currently resolve to the raw `fetch` `Response`. Every caller has to repeat the same boilerplate: check `response.ok`, then call `.text()`/`.json()` to get at the body — and if they also want to inspect the raw response (status, headers, or to parse the body a second way), they have to remember to `.clone()` it first, since a `Response` body can only be read once. There's also no way to tell TypeScript what shape a given endpoint's body has; every caller ends up writing their own `as User` cast. A small wrapper object that hands back the parsed body alongside an unread clone of the original response, with an optional generic for the body's shape, removes that repetitive, easy-to-get-wrong setup from every call site.

## What Changes

- Add a `FetchifyResponse<T = string>` type: `{ data: T | null; response: Response }`.
- `data` holds the response body, but only when the underlying response was `ok`; otherwise `data` is `null`. The body is read as text and then `JSON.parse`d: if parsing succeeds, `data` is the parsed value; if the body isn't valid JSON, `data` falls back to the raw text. Either way `data`'s static type is `T`, so a caller who knows an endpoint returns JSON can pass a generic (e.g. `client.get<User>("/user")`) and get a typed `data` back instead of casting it themselves.
- `response` holds a clone of the original `fetch` `Response`, taken before the body is consumed for `data`, so the caller still has an unread `Response` to call `.json()`/`.blob()`/`.text()` on, or to inspect `status`/`headers`/`ok`.
- Every `Fetchify` client method (`get`/`post`/`put`/`patch`/`delete`/`head`/`options`) becomes generic — `get<T = string>(path, init?): Promise<FetchifyResponse<T>>` — and resolves to `FetchifyResponse<T>` instead of the raw `Response`. This is a breaking change to `FetchifyMethod`'s signature and return type.

## Capabilities

### Modified Capabilities
- `http-client`: `FetchifyMethod` becomes generic and its return type changes from `Promise<Response>` to `Promise<FetchifyResponse<T>>`; the HTTP method wrappers now parse and return the response body (JSON, falling back to text) instead of returning the raw `Response` unmodified.

## Impact

- `src/types/fetchify-response.ts`: new module defining `FetchifyResponse<T = string>`.
- `src/types/fetchify-method.ts`: `FetchifyMethod` becomes generic (`<T = string>`), returning `Promise<FetchifyResponse<T>>`.
- `src/lib/create-fetchify.ts`: the internal `request` helper clones the raw response, conditionally reads `data` (JSON with a text fallback), and returns `{ data, response }`; each client method (`get`/`post`/etc.) forwards its own type parameter through to `request`.
- `src/index.ts` / `src/native.ts`: re-export `FetchifyResponse` alongside the existing exports.
- Existing tests that assert on a raw `Response` return value are updated to assert on `{ data, response }`, including a case exercising the generic (e.g. `client.get<User>(...)`).
- Breaking change — requires a `major` changeset.
42 changes: 42 additions & 0 deletions openspec/changes/custom-response-object/specs/http-client/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
## MODIFIED Requirements

### Requirement: HTTP method wrappers
Each of `get`, `post`, `put`, `patch`, `delete`, `head`, and `options` SHALL issue a `fetch` request using its corresponding uppercase HTTP method (e.g. `get` issues a request with method `GET`), forwarding any caller-supplied request-init options (headers, body, signal, etc.) other than `method`, and SHALL be generic over an optional type parameter `T` (defaulting to `string`), resolving to a `FetchifyResponse<T>` object: `{ data: T | null; response: Response }`.

`data` SHALL be populated only when the underlying `Response`'s `ok` is `true`: the body SHALL be read as text and `JSON.parse`d; if parsing succeeds, `data` SHALL be the parsed value, and if the body is not valid JSON, `data` SHALL be the raw text. When the underlying response is not `ok`, or the body cannot be read, `data` SHALL be `null`. `response` SHALL be a clone of the original `fetch` `Response`, taken before any body is read for `data`, so it remains fully unread and usable by the caller (`.json()`, `.blob()`, `.text()`, `status`, `headers`, `ok`, etc.).

#### Scenario: Method sets the correct HTTP verb
- **WHEN** a client calls `client.post("/users", { body: JSON.stringify({ name: "a" }) })`
- **THEN** the underlying request is sent with HTTP method `POST` and the given body

#### Scenario: Caller-supplied init.method is overridden
- **WHEN** a client calls `client.get("/users", { method: "POST" })`
- **THEN** the underlying request is still sent with HTTP method `GET`

#### Scenario: ok JSON response yields parsed data
- **WHEN** any HTTP method wrapper resolves, the underlying response's `ok` is `true`, and the body is valid JSON
- **THEN** the resolved value's `data` is the parsed JSON value

#### Scenario: ok non-JSON response yields text data
- **WHEN** any HTTP method wrapper resolves, the underlying response's `ok` is `true`, and the body is not valid JSON
- **THEN** the resolved value's `data` is the response body read as text

#### Scenario: Non-ok response yields null data
- **WHEN** any HTTP method wrapper resolves and the underlying response's `ok` is `false`
- **THEN** the resolved value's `data` is `null`

#### Scenario: Body read failure yields null data
- **WHEN** the underlying response's `ok` is `true` but reading its body fails
- **THEN** the resolved value's `data` is `null`

#### Scenario: response is an unread clone of the original Response
- **WHEN** any HTTP method wrapper resolves
- **THEN** the resolved value's `response` is a clone of the `Response` produced by the underlying `fetch` call, with its body not yet consumed, so the caller can independently call `.text()`, `.json()`, or read `status`/`headers`/`ok` on it

#### Scenario: Caller specifies a type parameter
- **WHEN** a consumer calls `client.get<User>("/user")`
- **THEN** the resolved value's `data` has static type `User | null`, and at runtime holds the JSON-parsed response body

#### Scenario: Caller omits the type parameter
- **WHEN** a consumer calls `client.get("/user")` without a type parameter
- **THEN** the resolved value's `data` has static type `string | null`, matching the default parse-then-fallback behavior
17 changes: 17 additions & 0 deletions openspec/changes/custom-response-object/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
## 1. Implement FetchifyResponse

- [x] 1.1 Define `FetchifyResponse<T = string>` (`{ data: T | null; response: Response }`) in `src/types/fetchify-response.ts`.
- [x] 1.2 Update `FetchifyMethod` in `src/types/fetchify-method.ts` to a generic function type `<T = string>(path: string, init?: RequestInit) => Promise<FetchifyResponse<T>>`.
- [x] 1.3 Update the internal `request` helper in `src/lib/create-fetchify.ts` to: call `fetch` as today, clone the raw response into `response` before reading any body, and — only when the raw response's `ok` is `true` — read the body as text, attempt `JSON.parse` on it, and use the parsed value as `data` if it succeeds or the raw text if it doesn't; fall back to `data: null` on a non-`ok` response or a read failure. Make `request` (and the returned `data`) generic over `T`.
- [x] 1.4 Update the `HTTP_METHODS`-driven client build in `create-fetchify.ts` so each of `get`/`post`/`put`/`patch`/`delete`/`head`/`options` forwards its own call-site type parameter (`<T = string>`) through to `request`, matching the `FetchifyMethod<T>` signature.
- [x] 1.5 Export `FetchifyResponse` from `src/index.ts` and `src/native.ts` alongside the existing exports.

## 2. Tests

- [x] 2.1 Update `src/lib/create-fetchify.test.ts` to assert the new `{ data, response }` shape: an `ok` JSON response yields the parsed object in `data`; an `ok` non-JSON response yields the raw text in `data`; a non-`ok` response yields `data: null`; `response` remains an independent, unread `Response` in every case (still readable via `.text()`/`.json()`/`status`/`headers`); each HTTP method still sends the correct verb.
- [x] 2.2 Add a test exercising the generic call pattern (e.g. `client.get<User>("/user")`) confirming `data` is typed and populated as the parsed `User` object.
- [x] 2.3 Run `bun test` and confirm all tests pass.

## 3. Release

- [x] 3.1 Run `bun run changeset` to record a `major` changeset describing the breaking change to `FetchifyMethod`'s signature and return type.
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export type { Fetchify } from "./lib/create-fetchify";
export { defineOptions } from "./lib/define-options";
export type { FetchifyOptions } from "./lib/define-options";
export type { FetchifyMethod } from "./types/fetchify-method";
export type { FetchifyResponse } from "./types/fetchify-response";
Loading
Loading