Skip to content
TypeWire

@tahanabavi/typefetch

documents v1.10.0

The typed HTTP client every other package is built around.

File Uploads

Set bodyType: "form-data" on an endpoint.

ts
const contracts = {
user: {
uploadAvatar: {
method: "POST",
path: "/users/:id/avatar",
bodyType: "form-data",
request: z.object({
path: z.object({
id: z.string(),
}),
body: z.object({
file: z.instanceof(File),
alt: z.string().optional(),
}),
}),
response: z.object({
uploaded: z.boolean(),
}),
},
},
} as const;

Usage:

ts
await api.user.uploadAvatar({
path: { id: "123" },
body: {
file,
alt: "Profile avatar",
},
});

When using form-data, TypeFetch does not force the Content-Type: application/json header.

For a progress bar on the upload, see Upload and Download Progress.


Response Types

By default a response body is read as JSON. Set responseType on the endpoint to read it as something else:

ts
import { z } from "zod";
import { zBlob, zFile } from "@tahanabavi/typefetch";

const contracts = {
report: {
downloadPdf: {
method: "GET",
path: "/reports/:id/pdf",
responseType: "blob", // ← new
request: z.object({ path: z.object({ id: z.string() }) }),
response: zBlob(),
},
},
} as const;

const pdf = await api.report.downloadPdf({ path: { id: "42" } });
// pdf: Blob
responseTypeResolves toSchema helper
"json"parsed JSON (default)any Zod schema
"text"stringz.string()
"blob"BlobzBlob()
"arrayBuffer"ArrayBufferzArrayBuffer()
"formData"FormDatazFormData()
"file"{ blob, filename, contentType, size }zFile()
"stream"ReadableStream | null (undrained)zStream()
"response"the whole Response (untouched)zResponse()

responseType lives on the contract, not on the call. The decoded value is what the endpoint's response schema validates, so a per-call override would silently invalidate the endpoint's inferred return type.

Schema helpers

Write response: zBlob(), not response: z.instanceof(Blob). The latter reads the global when the module is evaluated, so merely importing a contract that uses it throws a ReferenceError anywhere Blob is absent — older Node, and any server-side render that loads your shared contract file. Every helper here defers that lookup into the validator, so the schema is always constructible.

Downloading a file

responseType: "file" returns the blob together with the metadata you would otherwise re-derive from the response headers by hand:

ts
const { blob, filename, contentType, size } = await api.report.downloadFile({
path: { id: "42" },
});

const url = URL.createObjectURL(blob);
Object.assign(document.createElement("a"), {
href: url,
download: filename ?? "report.pdf",
}).click();
URL.revokeObjectURL(url);

filename is parsed from Content-Disposition, preferring the RFC 5987 filename* form, with any directory component stripped. It is undefined when the header is absent — including the common cross-origin case where the server did not send Access-Control-Expose-Headers: Content-Disposition, which makes the header invisible to the browser even though it was sent. Always keep a fallback name.

What is skipped for non-JSON types

setResponseWrapper and useResponseTransform apply to "json" and "text" only. Unwrapping an envelope from a Blob, or spreading one into a transform written for records, would corrupt exactly the payloads these response types exist for.

Error responses ignore responseType entirely. A 404 from a blob endpoint is still JSON, so failures are always read defensively — see Error Handling.


Response Wrappers

Many APIs return wrapped responses.

json
{
"success": true,
"data": {
"id": "123",
"name": "Taha"
},
"timestamp": "2026-01-01T00:00:00.000Z"
}

TypeFetch can validate and unwrap these responses.

ts
import { z } from "zod";

client.setResponseWrapper((successResponse) =>
z.union([
z.object({
success: z.literal(true),
data: successResponse,
timestamp: z.string().optional(),
requestId: z.string().optional(),
}),
z.object({
success: z.literal(false),
message: z.string(),
code: z.number().optional(),
timestamp: z.string().optional(),
requestId: z.string().optional(),
}),
]),
);

Successful responses return only data.

Failed wrapped responses throw RichError.


Upload and Download Progress

Pass onUploadProgress / onDownloadProgress as per-request options — progress is a call-site concern, not a contract fact:

ts
await api.media.upload(
{ body: { file } },
{
onUploadProgress: ({ percent, loaded, total }) => {
setProgress(percent ?? 0);
},
},
);

Each tick is a TransferProgress:

ts
type TransferProgress = {
phase: "upload" | "download";
loaded: number; // bytes so far
total?: number; // only when the length is known
percent?: number; // 0–100, two decimals, only when known
lengthComputable: boolean; // false → render an indeterminate bar
};

How upload progress works

fetch cannot report upload progress — there is no such API, and the streaming-request-body workaround (ReadableStream body + duplex: "half") is Chromium-only and requires HTTP/2.

So when — and only when — you pass onUploadProgress, TypeFetch swaps the final transport for XMLHttpRequest, which can. Your middleware is unaffected: the chain still receives the same context and is still handed a Response. A request without the handler takes the unchanged fetch path.

Three consequences worth knowing:

  • Node and SSR have no XMLHttpRequest. The request still runs over fetch and the handler is never called. TypeFetch warns once so the silence isn't mistaken for a stalled upload.

  • Some RequestInit fields have no XHR equivalent and are not carried over: cache, mode, redirect, referrerPolicy, integrity. credentials: "include" maps to withCredentials.

  • Retries restart progress. Each attempt re-sends the whole body and begins with a loaded: 0 tick, so a bar visibly resets rather than appearing stuck.

Download progress and CORS

Download progress runs on the normal fetch path by counting bytes off res.body. total and percent require a readable Content-Length — and cross-origin, that means the server must also list it in Access-Control-Expose-Headers. Without it you still get loaded ticks, with lengthComputable: false.

It is ignored for responseType: "stream" and "response", which hand you the undrained body — counting bytes there would consume the stream you asked to own.

In React

@tahanabavi/typefetch-react reads progress straight off the mutation's state, so no useState of your own:

tsx
const upload = useMutation(api.media.upload, { trackProgress: "upload" });

<progress value={upload.progress?.upload?.percent ?? 0} max={100} />;

trackProgress accepts true (both directions), "upload", or "download". It is off by default because neither side is free: upload tracking moves the request to XHR, download tracking re-streams the response body.