Installation
npm install @tahanabavi/typewire-nestjs @tahanabavi/typefetch zodPeer 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 point | Optional 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):
// 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:
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/:idis the same param syntax NestJS uses). Use a prefix-less@Controller(), since contract paths are absolute.Request validation —
params,query,body, and declaredheadersare validated againstendpoint.request. Failures return a400whose body the typefetch client surfaces as a first-classRichError(see below).Response validation — the handler's return value is validated against
endpoint.responseand stripped of undeclared fields, so entities never leak extra data. A mismatch logs the issues and returns500— backend drift from the contract can't ship silently.