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.

Global configuration

The decorators work with zero setup. Import the module to change defaults app-wide:

ts
@Module({
imports: [
TypeFetchModule.forRoot({
validateRequest: true, // default
validateResponse: true, // default
coerce: true, // default
exposeResponseErrors: process.env.NODE_ENV !== "production",
}),
],
})
export class AppModule {}

Every option can also be overridden per endpoint: @TypeFetchEndpoint(endpoint, { validateResponse: false }).

Field-level encryption (mirror of encryptionMiddleware)

When a contract endpoint sets an encryption config, the client's encryptionMiddleware encrypts the marked request fields before sending and decrypts the marked response fields on arrival. The backend mirrors it — decrypting request fields before validation and encrypting response fields after validation — using the same key material and the same algorithm (byte-compatible, via crypto-js / node-forge).

ts
// shared contract
const contracts = {
auth: {
login: {
method: "POST",
path: "/login",
encryption: {
method: "AES", // AES | DES | RSA | Base64 | Custom
request: { password: true }, // decrypt before validation
response: { token: true }, // encrypt after validation
},
request: z.object({ body: z.object({ username: z.string(), password: z.string().min(6) }) }),
response: z.object({ token: z.string(), user: z.string() }),
},
},
} as const;

Provide the same keyProvider the client uses:

ts
TypeFetchModule.forRoot({
encryption: {
keyProvider: async () => ({ type: "symmetric", key: process.env.ENC_KEY! }),
// RSA: () => ({ type: "rsa", publicKey, privateKey })
// customHandlers: { encrypt, decrypt } // for method: "Custom"
// failClosed: true (default) — never leak plaintext on crypto failure
},
})

Then handlers just work in plaintext:

ts
@TypeFetchEndpoint(contracts.auth.login)
login(@ContractInput() input: InferRequest<typeof contracts.auth.login>) {
// input.body.password is already decrypted AND validated (min(6) ran on plaintext)
return { token: signJwt(input.body.username), user: input.body.username };
// token is encrypted on the way out; the client decrypts it
}

Details that keep it interoperable:

  • Direction is mirrored — the client encrypts requests / decrypts responses, so the server decrypts requests / encrypts responses.

  • Order matters — request fields are decrypted before validation (so min(6) etc. run on plaintext), response fields are encrypted after validation.

  • Value serialization matches — non-string values are JSON.stringifyd before encryption and safeJsonParsed after decryption, exactly like the client, so numbers/objects round-trip.

  • Per-direction methodsmethod: { request: "AES", response: "Base64" } is honored; a bare string applies to both. Deep maps ({ profile: { pin: true } }) and the { body: { ... } } request style are supported.

  • crypto-js and node-forge are optional peers — required lazily, only when encryption is used. failClosed (default true) turns any crypto failure into a 400 DECRYPTION_ERROR / 500 ENCRYPTION_ERROR rather than leaking plaintext.

OpenAPI / Swagger from contracts

The same contracts generate a full OpenAPI 3.0 document — method, path, params, request body, and responses all derived from the Zod schemas, so your API docs can never drift from what the client calls.

ts
import { NestFactory } from "@nestjs/core";
import { setupContractSwagger } from "@tahanabavi/typewire-nestjs";
import { contracts } from "./contracts";

const app = await NestFactory.create(AppModule);

setupContractSwagger(app, contracts, {
path: "docs",
info: { title: "My API", version: "1.0.0" },
});

await app.listen(3000);
// Swagger UI → http://localhost:3000/docs
// Raw JSON → http://localhost:3000/docs-json

@nestjs/swagger is an optional peer dependency — install it only if you use setupContractSwagger(). Prefer to serve the document yourself? Build the plain object directly:

ts
import { buildOpenApiDocument } from "@tahanabavi/typewire-nestjs";

const document = buildOpenApiDocument(contracts, { info: { title: "My API", version: "1.0.0" } });
// hand to SwaggerModule.setup(), write to disk, feed a client generator, ...

What the generator maps from each contract endpoint:

ContractOpenAPI
path: "/users/:id"/users/{id} with a required id path parameter
request.path / request.query / request.headerstyped parameters (array query params → explode: true, matching repeated-key serialization)
request.body (or a flat request)requestBodyapplication/json, or multipart/form-data when bodyType: "form-data"
responsethe success response (201 for POST, 200 otherwise)
z.date() / z.bigint() / file fieldsstring+date-time / string+int64 / string+binary (never throws)
auth: truebearerAuth security requirement + documented 401
a shared ContractValidationError schema on every 400

Options: bearerAuth (default on), includeValidationError (default on), servers, and successStatus(endpoint) to override the documented success code.