Skip to content
TypeWire

@tahanabavi/typewire-nestjs

documents v0.1.1

NestJS integration for the TypeWire ecosystem — serve one set of Zod contracts over every wire your client speaks: REST/HTTP, gRPC (Connect JSON), GraphQL and typesocket WebSocket gateways, with request/response validation on all of them.

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:

ts
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:

txt
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:

ts
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.

ts
@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():

ts
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:

ts
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:

ts
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:

ts
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 File check 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 with FileInterceptor, FilesInterceptor, FileFieldsInterceptor, and AnyFilesInterceptor alike.

  • 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 reports body.<field>: ["Expected an uploaded file"].