File Uploads
Set bodyType: "form-data" on an endpoint.
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:
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:
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: BlobresponseType | Resolves to | Schema helper |
|---|---|---|
"json" | parsed JSON (default) | any Zod schema |
"text" | string | z.string() |
"blob" | Blob | zBlob() |
"arrayBuffer" | ArrayBuffer | zArrayBuffer() |
"formData" | FormData | zFormData() |
"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:
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.
{
"success": true,
"data": {
"id": "123",
"name": "Taha"
},
"timestamp": "2026-01-01T00:00:00.000Z"
}TypeFetch can validate and unwrap these responses.
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:
await api.media.upload(
{ body: { file } },
{
onUploadProgress: ({ percent, loaded, total }) => {
setProgress(percent ?? 0);
},
},
);Each tick is a TransferProgress:
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 overfetchand the handler is never called. TypeFetch warns once so the silence isn't mistaken for a stalled upload.Some
RequestInitfields have no XHR equivalent and are not carried over:cache,mode,redirect,referrerPolicy,integrity.credentials: "include"maps towithCredentials.Retries restart progress. Each attempt re-sends the whole body and begins with a
loaded: 0tick, 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:
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.