Non-JSON responses (responseType)
An HTTP contract can declare how its success body is decoded — "text",
"blob", "arrayBuffer", "formData", "file", "stream" or "response".
Two things follow on the server, and both are handled:
Bytes are sent as bytes. A Buffer returned from a NestJS handler is
JSON-serialised into {"type":"Buffer","data":[37,80,…]}. Return
contractFile() (or a bare Buffer/Readable) and it is streamed instead:
import { contractFile } from "@tahanabavi/typewire-nestjs";
@TypeFetchEndpoint(contracts.report.download) // responseType: "file"
download() {
return contractFile(await this.render(), {
filename: "Q3 résumé.pdf",
contentType: "application/pdf",
});
}Content-Disposition is sent in both RFC 6266 spellings, so a non-ASCII name
survives and the ASCII fallback cannot inject a header parameter of its own.
Content-Length is sent whenever it is known — it is what makes the client's
download progress report a percentage instead of an indeterminate spinner.
The response schema is not enforced for those types, because it cannot be:
zBlob() matches a browser Blob and zFile() a { blob, filename, … } —
values that only exist after the client decodes the body. Returning the wrong
kind of thing is caught instead, with a message that names the fix:
Endpoint declares responseType: "file" but the handler returned a plain Object.
Return a Buffer, a Readable, or contractFile(body, { filename, contentType })."json" and "text" are still validated normally, and "text" is sent as
text/plain rather than Express's default text/html.
fetch and xhr
The contract's driver ("auto" / "fetch" / "xhr") is a client-side
choice: both produce the same HTTP request, and there is nothing for a server to
do differently. What the server does owe them is the two headers a browser
hides cross-origin — without which a download loses its filename and a progress
bar can never fill:
import { CONTRACT_EXPOSED_HEADERS } from "@tahanabavi/typewire-nestjs";
app.enableCors({ exposedHeaders: [...CONTRACT_EXPOSED_HEADERS] });They are set on the response automatically; CORS is the only place they can be granted.
@SkipEnvelope()
The response envelope is app-wide, which is right for an API whose client reads one wrapper shape — and wrong for anything whose body shape is fixed by someone else: a health check a load balancer parses, a webhook receipt, an OAuth callback.
@Get("/healthz")
@SkipEnvelope()
health() {
return { status: "ok" };
}It exempts the success wrapper and the error branch, so a failing exempt route answers with its own shape too. gRPC routes and the GraphQL endpoint carry it automatically: each brings an envelope of its own.
Response envelope (mirror of setResponseWrapper)
The typefetch client can wrap every response in an envelope via client.setResponseWrapper():
client.setResponseWrapper((successResponse) =>
z.union([
z.object({ success: z.literal(true), data: successResponse }),
z.object({ success: z.literal(false), message: z.string() }),
]),
);Enable the matching server side so the client's wrapper parses both branches:
TypeFetchModule.forRoot({ envelope: true })Successful responses are wrapped in
{ success: true, data }— after contract-response validation (the envelope interceptor is global, so it runs outside the method-scoped validator).Every error — contract 400s, response-violation 500s, and any other
HttpException— is formatted by a catch-all filter into{ success: false, message, code?, errors? }, keeping the original HTTP status. This matters: with a client wrapper active, an unwrapped error body would fail the client's schema parse, so the envelope must cover failures too.It applies to all routes (contract-bound or not), so the whole API has one shape.
Customize the shape (must match your client wrapper) or return errors as 200:
TypeFetchModule.forRoot({
envelope: {
success: (data) => ({ ok: true, result: data }),
error: (e) => ({ ok: false, reason: e.message, code: e.code }),
errorStatus: 200, // default "preserve" keeps the real HTTP status
},
})e is { message, status, code?, errors? }. Disabled by default; ResponseEnvelopeInterceptor and ContractEnvelopeExceptionFilter are also exported for manual wiring.
File uploads (bodyType: "form-data")
When a contract sets bodyType: "form-data" with file fields (z.instanceof(File) / z.file()), the request is multipart, not JSON. Add any NestJS file interceptor and typewire-nestjs handles the rest — place it closest to the method so Multer parses the body before validation runs:
import { UseInterceptors } from "@nestjs/common";
import { AnyFilesInterceptor } from "@nestjs/platform-express";
@Controller()
class MediaController {
@TypeFetchEndpoint(contracts.media.uploadAvatar)
@UseInterceptors(AnyFilesInterceptor()) // ← runs before contract validation
upload(@ContractInput() input: InferRequest<typeof contracts.media.uploadAvatar>) {
// input.body.file → the uploaded Multer file (passed through)
// input.body.priority → coerced number, input.path.id → validated
return { id: input.path.id, filename: input.body.file.originalname };
}
}How each form field is validated:
File fields — a browser
instanceof Filecheck can't hold on the server, so the uploaded Multer file is passed through after a presence check that honors the field's optionality.z.array(z.instanceof(File))collects multiple files; a single upload to an array field is wrapped. Works withFileInterceptor,FilesInterceptor,FileFieldsInterceptor, andAnyFilesInterceptoralike.Text fields — multipart sends everything else as strings, so they're coerced toward the declared type (
"3"→3,"true"→true) exactly like query params, then validated. Undeclared fields are dropped; a missing required file reportsbody.<field>: ["Expected an uploaded file"].