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.

gRPC — Connect JSON, no protobuf

@tahanabavi/typefetch-grpc speaks Connect's JSON protocol by default: a plain POST /<service>/<rpc> with the message as the body, real HTTP status codes, curl-able. That is an HTTP route, so NestJS can serve it — with no protobuf runtime, no .proto file and no code generation anywhere.

ts
import { Controller } from "@nestjs/common";
import { ContractInput, InferRequest } from "@tahanabavi/typewire-nestjs";
import { GrpcEndpoint, GrpcException, GrpcCode } from "@tahanabavi/typewire-nestjs/grpc";

@Controller() // prefix-less: the service name
export class UserRpcController { // fully qualifies the route
@GrpcEndpoint(contracts.user.getUser) // POST /user.v1.UserService/GetUser
async getUser(@ContractInput() input: InferRequest<typeof contracts.user.getUser>) {
const user = await this.users.byId(input.id);
if (!user) throw new GrpcException(GrpcCode.NotFound, `No user ${input.id}`);
return user; // validated against `response`
}
}

Failures are named in gRPC's key space, which is what a gRPC endpoint's errors map is keyed by. Throw a GrpcException to say exactly which code, or throw the NestJS exception you already would and let it map:

Handler throwsCodeHTTP
GrpcException(GrpcCode.NotFound)not_found404
BadRequestException / a contract violationinvalid_argument400
UnauthorizedExceptionunauthenticated401
ForbiddenExceptionpermission_denied403
NotFoundExceptionnot_found404
ConflictExceptionalready_exists409
anything elseinternal500

That table is deliberately not the client's codeFromHttpStatus. There, a 404 came from something that never reached the RPC, so it means "no such method" — unimplemented. Here it came from a handler that looked and did not find, which is not_found. Same status, opposite meaning, because the thrower is different.

Deadlines are enforced, not just received. The client sends the contract's deadlineMs as Connect-Timeout-Ms (and grpc-timeout); a deadline only the client honours is a timeout — the caller gives up and the server keeps the database connection. @GrpcDeadline() hands the handler an AbortSignal so the work can stop too:

ts
@GrpcEndpoint(contracts.report.build)
build(@ContractInput() input, @GrpcDeadline() deadline?: GrpcDeadlineInfo) {
return this.db.query(sql, { signal: deadline?.signal });
}

When the deadline passes, the RPC answers deadline_exceeded / 504.

Two details that are easy to get wrong and are handled: a unary call answers 200, not Nest's default 201 for a POST; and the global response envelope is never applied — a Connect client reading { success: false, message } sees no code at all and classifies every failure as unknown.

Not served: binary grpc-web. An endpoint with a codec sends application/grpc-web+proto, which needs raw-body access and length-prefixed trailer framing on the way out. @GrpcEndpoint() refuses it at bootstrap rather than answering JSON to a protobuf client. Put a grpc-web proxy in front, or drop codec and use JSON on both ends.

GraphQL — no GraphQL runtime

@tahanabavi/typefetch-graphql generates its operation document from the endpoint's Zod response schema. Both halves of the contract therefore agree on the selection set by construction — which means an operation can be addressed by name and answered from the same schema that asked for it. No graphql package, no SDL file, no resolver map.

ts
import { Module, Injectable } from "@nestjs/common";
import { ContractInput, InferRequest } from "@tahanabavi/typewire-nestjs";
import {
ContractGraphQLModule,
GraphQLEndpoint,
} from "@tahanabavi/typewire-nestjs/graphql";

@Injectable()
export class UserResolver {
@GraphQLEndpoint(contracts.user.profile) // transport: "graphql"
profile(@ContractInput() input: InferRequest<typeof contracts.user.profile>) {
return this.users.byId(input.id); // → { data: { user: { … } } }
}
}

@Module({
imports: [ContractGraphQLModule.forRoot({ contracts })],
providers: [UserResolver],
})
export class AppModule {}

A resolver is an ordinary NestJS provider. It declares no route — every operation arrives at one endpoint (/graphql by default) which dispatches on the operation name — and it is discovered at bootstrap, so nothing is listed twice.

contracts is required, and not merely convenient: a GraphQL operation is addressed by name, and the name the client sends is derived from the endpoint's "module.endpoint" id (user.profileUserProfile). An endpoint object on its own does not know its own id, so the only way to reconstruct that name is to look the object up in the map it came from.

What you get:

  • The result is nested under root when the contract declares one, and sits directly under data when it does not — matching what the generated document selected.

  • Guards and interceptors run. The resolver goes through NestJS's own pipeline, so @UseGuards(PermissionGuard) enforces the contract's permission key in a resolver exactly as it does on a route.

  • @ContractInput() returns the validated variables, the same decorator an HTTP handler uses.

  • Failures are named in GraphQL's key spaceextensions.code, which is what a GraphQL endpoint's errors map is keyed by. NotFoundExceptionNOT_FOUND, a contract violation → BAD_USER_INPUT, ForbiddenExceptionFORBIDDEN; or throw GraphqlException(code, message) to say it outright.

  • Media type negotiation. A client that accepts application/graphql-response+json gets the real HTTP status on a failure; one that accepts only application/json gets the spec's 200 with the intended status in extensions.http.status, which is what typefetch reads.

  • GET for queries (?query=&variables=), so a query can be CDN-cached. Mutations are refused over GET — a mutation a cache can replay is a way to lose a write.

A contract server, not a GraphQL server

The trade is worth stating plainly. The document is not executed. The operation is resolved by name and the response is the endpoint's response schema — which is exactly what the client's generated selection set asked for.

That is correct for any client generated from the same contract, and it is not a general GraphQL server: there is no introspection, no schema stitching, no field-level resolvers, no aliases and no fragments. A hand-written query selecting a subset gets the full contract shape back (harmless — the client's schema strips it); one using an alias will not resolve. If you need any of that, @nestjs/graphql is the right tool and this does not try to replace it.

WebSocket gateways (typesocket)

One contract object serves both ends. typesocket events declare their own direction, so the client emits client->server and listens to server->client — and a gateway does exactly the reverse, from the same file, with no mirrored second declaration to drift.

ts
import { WebSocketGateway, WebSocketServer } from "@nestjs/websockets";
import {
SocketEvent,
SocketPayload,
bindSocketContracts,
createSocketEmitter,
type InferSocketRequest,
} from "@tahanabavi/typewire-nestjs/socket";
import { wsContracts } from "./contracts/chat.contracts";

export const events = bindSocketContracts(wsContracts);

@WebSocketGateway()
export class ChatGateway {
@WebSocketServer() server!: Server;
private readonly emit = createSocketEmitter(() => this.server);

@SocketEvent(events.chat.sendMessage) // wire name from the contract
async send(@SocketPayload() input: InferSocketRequest<typeof events.chat.sendMessage>) {
const message = await this.chat.post(input.text);
this.emit(events.chat.message, message); // validated against `payload`
return { id: message.id }; // validated against `ack`
}
}

bindSocketContracts() exists because a definition object does not know its own name: the wire event name defaults to "module.event", which is a fact about where it sits in the map. Binding resolves it once, the same way the client does, and refuses two events that collide on one wire name — where a single frame would reach both handlers and both would try to acknowledge it.

Three checks, one per direction that can break:

CheckedAgainstWhy it matters
inbound framerequestvia @SocketPayload() — nothing else validates what a sender put on the wire
acknowledgementacktypesocket validates the ack on arrival, so a drifted one rejects far from the server that sent it
server pushpayloadtypesocket drops an inbound payload that fails its schema — a renamed field turns into a listener that silently stops firing

createSocketPermissionGuard() enforces a client->server event's contract permission server-side. typesocket ships a client-side equivalent and says of it that the check is UX only — this is the real enforcement point:

ts
export const SocketPermissionGuard = createSocketPermissionGuard({
getPermissions: (client) => client.data.perms as bigint,
authorize: P.authorize,
});

@WebSocketGateway()
@UseGuards(SocketPermissionGuard)
export class ChatGateway { /* … */ }

On failures. A rejected frame throws SocketContractException, which NestJS sends as socket.io's standard exception event; the frame is not acknowledged. An ack is not an error channel — typesocket validates it against ack, so an error object sent there would arrive as a malformed ack rather than as the rejection it is. An event whose failures the caller must handle should say so in its contract: ack: z.discriminatedUnion("ok", [ … ]), the same rule that governs errors on an HTTP endpoint.