InteractiveFrameworks

Pipes

Validate what a socket sends: the ValidationPipe on a gateway, refusals turned into WsException so the client learns what was wrong, and a parameter pipe whose ordinary BadRequestException would reach the client only as Internal server error.

What you'll learn

  • Validate message payloads with a ValidationPipe bound to a gateway with @UsePipes()
  • Explain why a pipe's BadRequestException reaches a socket as Internal server error, and fix it with an exceptionFactory returning a WsException
  • Bind a pipe to one parameter, @MessageBody('id', pipe), and give it its own exceptionFactory

A socket's payload is as untrusted as a request body, and arrives with less ceremony: no content type, no route that already narrowed it, just an event name and whatever JSON the client chose to send. A cat named M with an age of -1 is as wrong over a socket as over HTTP, and the same tools check it: the DTO with class-validator's rules, and the ValidationPipe. What changes is how a refusal reaches the client, and that difference catches nearly everyone the first time.

The same pipes, a different refusal

Pipes run on a gateway's handler parameters exactly as on a controller's: @UsePipes() on the gateway class or a handler, app.useGlobalPipes() for the whole app, or a pipe given to one parameter, @MessageBody('id', ParseIntPipe). A ValidationPipe validates a @MessageBody() typed with a DTO, and transforms it if asked.

But a pipe refuses by throwing, and the pipes Nest ships throw BadRequestException, an HTTP exception. The previous lesson showed what a gateway does with that: the client receives Internal server error, and the list of messages that would have told it what was wrong stays on the server.

{ "status": "error", "message": "Internal server error", "cause": { "pattern": "kennels:book", "data": { "nights": -2 } } }

exceptionFactory

Every built-in pipe takes an exceptionFactory option: a function from the pipe's error to the exception it throws. For a gateway, return a WsException. The ValidationPipe gives it the list of ValidationErrors, each with the failed rules' messages in constraints:

@UsePipes(
  new ValidationPipe({
    exceptionFactory: (errors) =>
      new WsException(errors.map((error) => ({ field: error.property, problems: Object.values(error.constraints ?? {}) }))),
  }),
)
@WebSocketGateway()
export class KennelsGateway {}

A WsException with an object or an array is sent exactly as it is, so the shape is yours: this one sends [{ "field": "nights", "problems": ["nights must not be less than 1"] }]. A parameter pipe takes the same option, and its error is the message it would have put in the BadRequestException:

@SubscribeMessage('kennels:book')
book(@MessageBody('nights', new ParseIntPipe({ exceptionFactory: (error) => new WsException(error) })) nights: number) {}

The docs' own example passes the errors straight to new WsException(errors). That works, but sends class-validator's whole ValidationError objects, target object and all, which is more than a client needs and sometimes more than it should see.

Where pipes run

Pipes run per handler parameter, after guards and before the handler, as over HTTP. A gateway-level @UsePipes() applies to every parameter of every handler in it. For a @MessageBody('id') of type number, the ValidationPipe has no class to validate and passes the value on unchanged: a string "2" stays a string, which is why a parameter that must be a number gets ParseIntPipe, which turns "2" into 2 and refuses "abc".

Your task

CreateCatDto carries the rules: a name of at least two characters, an age that is a whole number, not negative.

  1. Validate every message's payload against its DTO, for the whole gateway. A refusal reaches the client as a WsException carrying the list of messages: ["name must be longer than or equal to 2 characters", "age must not be less than 0"].
  2. cats:find refuses an id that is not a number with ParseIntPipe's own message, Validation failed (numeric string is expected), as a WsException; a numeric string such as "2" finds Luna.

Before step 2, emit cats:find with { "id": "abc" } in the Socket mode and read what arrives.

When it fails

  • "message":"Internal server error" for an invalid cat or id: the pipe has no exceptionFactory, or its factory returns an HTTP exception; only a WsException reaches the client with its content.
  • acknowledged with {"id":3,"name":"M","age":-1}: nothing validated the payload. The ValidationPipe is not bound, or the handler's parameter is typed with an interface, which leaves the pipe nothing to validate against.
  • ada received [{"event":"exception","data":[{"target":{…},"property":"name",…}]}]: the factory passed the ValidationError objects themselves; send their constraints messages.
  • Validation failed (numeric string is expected) for every cats:add: ParseIntPipe was bound to the whole gateway with @UsePipes(), so it also received the cat object. Give it to the one parameter.

Remember

  • Pipes work on gateways as on controllers: per parameter, after guards.
  • Nest's pipes throw BadRequestException, which a socket client sees as Internal server error.
  • exceptionFactory returning a WsException makes the refusal reach the client with its messages.
  • A WsException with an array or object sends that exact shape; choose it for the client.
Stuck? Show a hint

@UsePipes(new ValidationPipe({ exceptionFactory: (errors) => new WsException(errors.flatMap((e) => Object.values(e.constraints ?? {}))) })) on the gateway class. find: @MessageBody('id', new ParseIntPipe({ exceptionFactory: (error) => new WsException(error) })).