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