Skip to content
TypeWire

@tahanabavi/typewire-nestjs

v4.0.0

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 validation — params, 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.