Read Non-JSON Responses With a Typed Stitch
Oleksandr Zhuravlov
The report endpoint returns CSV. The scraping target returns HTML. The files service hands back a PDF or a zip. Your client was built for JSON, so each of these becomes a one-off: a manual fetch, a res.text() here and a res.arrayBuffer() there, a parser bolted on, and a return type of any that lies to everyone downstream. A stitch reads those bodies too — responseType picks how the body is decoded, transform reshapes it, and an output schema validates the shape you actually use.
The honest before
Here is a CSV report read by hand. The job is small, but the sharp edges are real:
async function getRevenueReport(month: string) {
for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch(
`https://api.example.com/reports/${month}.csv`,
{
headers: { authorization: `Bearer ${process.env.API_TOKEN}` },
},
);
if (res.status === 503) continue; // hope it works next time
const text = await res.text(); // res.json() would throw on a comma
return parseCsv(text); // returns... whatever parseCsv returns
}
throw new Error('report failed');
}Nothing here is wrong, but nothing here is reusable. You chose res.text() by hand because res.json() would have thrown on a comma. The return shape is whatever parseCsv happens to give you. A flaky gateway is your problem to handle, and the bearer token is captured in the closure. Every non-JSON endpoint in the codebase grows its own copy of this.
The stitch version
The same job as a declaration. Each field replaces a piece of that glue:
import { bearer, env, stitch } from 'stitchapi';
import { z } from 'zod';
const getRevenueReport = stitch({
url: 'https://api.example.com/reports/{month}.csv',
responseType: 'text', // decode the body as a string, not guessed JSON
transform: (raw) => parseCsv(raw as string), // your CSV parser
output: z.array(z.object({ sku: z.string(), revenue: z.number() })),
auth: bearer(env('API_TOKEN')),
retry: 3,
});
const rows = await getRevenueReport({ params: { month: '2026-06' } });
// rows: { sku: string; revenue: number }[]responseType: 'text' forces the body to be decoded as a string instead of guessed by content-type. transform runs your CSV parser over that raw string. output validates the parsed rows, so rows is fully typed and coerced — revenue is a number, not the string the CSV held. auth resolves the token at call time, so the caller never holds it. retry: 3 makes a transient 503 the stitch's problem, not yours. The next CSV endpoint is another five-line declaration, not another copy of the hand-rolled fetch-and-parse block.
responseType: pick the decode, or let it pick
responseType is one of four values, and the default is auto:
- default (omitted) — JSON when the content-type says so, else text. This is the right choice for ordinary JSON APIs; you never set it.
'text'— force a string. Use it for HTML, CSV, or plain text, where content-type sniffing might guess wrong or the parser you want runs on the raw string.'arrayBuffer'— force bytes as anArrayBuffer. Use it for binary: an image, a PDF, a zip.'blob'— force aBlob, the better shape when you hand bytes to a browser API.
For binary you set responseType and skip transform if the bytes are the deliverable:
const invoicePdf = stitch({
url: 'https://api.example.com/invoices/{id}.pdf',
responseType: 'arrayBuffer',
auth: bearer(env('API_TOKEN')),
});
const bytes = await invoicePdf({ params: { id: 'inv_2026_06' } });
// static type: unknown (no output schema); an ArrayBuffer at runtimeOutput inference reads the output schema, never responseType, so a stitch with no output is Stitch<unknown> — the value is an ArrayBuffer at runtime, but the type system does not know that. Add an output validator or an explicit generic when you want the static type to say ArrayBuffer.
Setting responseType is the difference between a body the engine guessed at and a body decoded the way the endpoint actually answers.
transform: reshape the raw body before validation
transform runs on the raw decoded body, before the output schema sees it:
const fetchArticle = stitch({
url: 'https://example.com/articles/{slug}',
responseType: 'text', // an HTML string
transform: (html) => scrapeArticle(html as string), // your scraper -> object
output: z.object({
title: z.string(),
author: z.string(),
body: z.string(),
}),
});
const article = await fetchArticle({ params: { slug: 'stitching-apis' } });
// article: { title: string; author: string; body: string }It is the seam where you turn a non-JSON payload into the structured value your code wants — parse a CSV string into rows, scrape an HTML string into fields, decode bytes into a record. Pair it with output and the transformed shape is what gets validated and typed, not the raw string.
The endpoint returns HTML. responseType: 'text' hands transform a string, your scraper turns it into an object, and output proves that object has the three fields you claimed. Downstream code sees a typed article, never a tag soup.
Parsing is bring-your-own — on purpose
StitchAPI does not ship an HTML-to-Markdown converter or a CSV parser. That is a design choice, not a gap. The core is zero runtime dependencies, and bundling a parser would break two of the gates every feature passes: contract-not-dependency (you bring the converter, the core defines the seam) and bundle-frugal (a CSV library does not belong in everyone's bundle).
So you call your own converter inside transform:
import { stitch } from 'stitchapi';
import Turndown from 'turndown';
// your dependency, your version
const td = new Turndown();
const fetchAsMarkdown = stitch({
url: 'https://example.com/docs/{slug}',
responseType: 'text',
transform: (html) => td.turndown(html as string),
output: z.string(),
});The parser lives in your package.json, where you can pin it, swap it, or drop it. The stitch only knows the contract: a function from raw body to a value, validated on the way out.
What changes, axis by axis
The left column is glue you rewrite per endpoint. The right column is configuration you declare per endpoint and read the same way every time.
| Axis | By hand | A stitch |
|---|---|---|
| Decode | res.text() / res.arrayBuffer() picked by hand | responseType names the decode |
| Reshape | parser called inline, ad hoc | transform is the declared seam |
| Return type | whatever the parser returns (any) | output validates and types the result |
| Resilience | hand-rolled retry loop | retry / timeout are fields |
| Credential | token captured in the closure | auth resolves at call time |
| Next endpoint | copy the block | another short declaration |
It is still a stitch
Reading HTML or bytes does not put you on a side path. The same configuration that surrounds a JSON call surrounds this one — auth, retry, timeout, and trace apply regardless of the body format, because the body format is one field:
const fetchPage = stitch({
url: 'https://example.com/pages/{slug}',
responseType: 'text',
transform: (html) => scrapePage(html as string),
output: PageSchema,
auth: bearer(env('API_TOKEN')),
retry: { attempts: 3, on: [429, 503], backoff: 'expo-jitter' },
timeout: { total: '30s', perAttempt: '10s' },
trace: 'console',
});A scalar JSON call is one line — stitch({ url }), and the auto responseType returns parsed JSON. A non-JSON call is the same declaration with the decode named and a converter wired in, and it gains resilience and validation by adding fields, never by rewriting the call. The endpoint that answers CSV today and JSON tomorrow stays one declaration; you change responseType and the transform, and every caller reads it the same way.
Try it
Declare a stitch with responseType: 'text', run your own parser in transform, and validate the result with an output schema — a typed function over any non-JSON endpoint.
npm install stitchapi@rc- transform — the reshape seam, in full.
- Validation — how
outputtypes and checks the result. - Helpers reference — adapters and the rest of the surface.
- Read non-JSON, then compose it — chain these stitches into one pipeline.