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.

Installation

bash
npm install @tahanabavi/typewire-nestjs @tahanabavi/typefetch zod

Peer dependencies: @nestjs/common + @nestjs/core (v10 or v11), rxjs, reflect-metadata, zod@^4.

Every non-HTTP wire ships behind its own entry point, so an HTTP-only app installs nothing extra. Each one's peer is a package the contract already needed — see Every wire the client speaks:

Entry pointOptional peer
@tahanabavi/typewire-nestjs/grpc@tahanabavi/typefetch-grpc
@tahanabavi/typewire-nestjs/graphql@tahanabavi/typefetch-graphql
@tahanabavi/typewire-nestjs/socket@nestjs/websockets + @tahanabavi/typesocket

Quick start

The shared contract (imported by both frontend and backend):

ts
// contracts/user.contracts.ts
import { z } from "zod";
import type { Contracts } from "@tahanabavi/typefetch";

export const contracts = {
user: {
getUser: {
method: "GET",
path: "/users/:id",
request: z.object({
path: z.object({ id: z.string() }),
query: z.object({ verbose: z.boolean().optional() }).optional(),
}),
response: z.object({ id: z.string(), name: z.string() }),
},
createUser: {
method: "POST",
path: "/users",
auth: true,
request: z.object({
body: z.object({ name: z.string().min(2), age: z.number().int() }),
}),
response: z.object({ id: z.string(), name: z.string() }),
},
},
} as const satisfies Contracts;

The controller:

ts
import { Controller } from "@nestjs/common";
import {
TypeFetchEndpoint,
ContractInput,
InferRequest,
InferResponse,
} from "@tahanabavi/typewire-nestjs";
import { contracts } from "./contracts/user.contracts";

type GetUser = typeof contracts.user.getUser;
type CreateUser = typeof contracts.user.createUser;

@Controller()
export class UserController {
// GET /users/:id — method + path come from the contract. No @Get, no drift.
@TypeFetchEndpoint(contracts.user.getUser)
async getUser(
@ContractInput() input: InferRequest<GetUser>,
): Promise<InferResponse<GetUser>> {
return { id: input.path.id, name: "Taha" };
}

@TypeFetchEndpoint(contracts.user.createUser, { httpCode: 200 })
async createUser(
@ContractInput() input: InferRequest<CreateUser>,
): Promise<InferResponse<CreateUser>> {
return { id: "u-1", name: input.body.name };
}
}

That's it. For every bound endpoint:

  • Route — HTTP method and path are taken from the contract (/users/:id is the same param syntax NestJS uses). Use a prefix-less @Controller(), since contract paths are absolute.

  • Request validationparams, query, body, and declared headers are validated against endpoint.request. Failures return a 400 whose body the typefetch client surfaces as a first-class RichError (see below).

  • Response validation — the handler's return value is validated against endpoint.response and stripped of undeclared fields, so entities never leak extra data. A mismatch logs the issues and returns 500 — backend drift from the contract can't ship silently.