Skip to content

Validate Chilean RUT in NestJS with class-validator

Build a reusable `@IsRut()` decorator with rut.ts (zero deps), plug it into NestJS' ValidationPipe, and ship strict mode, DTOs, and the 400 response.

Arrow Software Team
Arrow Software TeamApril 14, 2026 · 5 min read
A dimly lit server room with rows of racks
RUTs must be validated again on the server. Source: Unsplash.

Every endpoint that accepts a RUT must re-validate on the server. The client-side form is a UX layer, not a trust boundary — a determined caller can bypass it entirely with a curl command, and a misconfigured front end can send values that never ran through the schema in the first place. NestJS makes server-side RUT validation elegant when you wrap rut.ts in a custom class-validator decorator: one file, one decision about strict policy, and then every DTO in the project inherits the same gate with a single annotation. Because rut.ts ships zero runtime dependencies, the decorator adds no transitive packages to your Nest project.

How do I validate a Chilean RUT in NestJS?#

Wrap validate() from rut.ts in a custom class-validator decorator. The decorator below pins { strict: true } so every DTO that uses @IsRut() rejects malformed inputs, wrong check digits, and placeholder RUTs like 11.111.111-1 — at the ValidationPipe layer, before your controller body runs.

Why a custom decorator#

The direct alternative is calling validate(rut, { strict: true }) inside each controller action or service method. That works, but it scatters the decision across the codebase in a way that is hard to audit and easy to get inconsistent. One controller might pass strict: true, another might forget it, a third might not call validate() at all and trust that the client already did. The inconsistency is not visible at compile time — it surfaces at runtime when a placeholder like 11.111.111-1 makes it through to the database or an external identity check.

A custom decorator solves this at the architectural level. DTOs become declarative: @IsRut() rut: string reads like the schema you are trying to describe. The strict policy lives in one place. When a new endpoint is added, the developer reaches for @IsRut() the same way they would reach for @IsEmail() or @IsUUID() — the validation intent is expressed in the field definition, not buried in business logic.

Implementing @IsRut()#

class-validator exposes registerDecorator for exactly this purpose: wrapping arbitrary validation logic in a decorator that integrates with the same metadata system as the built-in decorators. The validator receives the raw value from the request body and must return a boolean. rut.ts's validate() already has that signature.

TypeScript
// src/common/validators/is-rut.decorator.ts
import {
  registerDecorator,
  type ValidationOptions,
  type ValidationArguments,
} from "class-validator";
import { validate as isRut } from "rut.ts";
 
export function IsRut(options?: ValidationOptions) {
  return function (object: object, propertyName: string) {
    registerDecorator({
      name: "isRut",
      target: object.constructor,
      propertyName,
      options: { message: "$property must be a valid Chilean RUT", ...options },
      validator: {
        validate(value: unknown) {
          return typeof value === "string" && isRut(value, { strict: true });
        },
        defaultMessage(args: ValidationArguments) {
          return `${args.property} must be a valid Chilean RUT`;
        },
      },
    });
  };
}

A few things worth noting. The typeof value === "string" guard runs first, so a missing or null body field returns false without throwing a type error inside isRut(). The options spread comes after the default message, which means a caller can override the message per field — @IsRut({ message: "El RUT ingresado no es válido" }) — without having to fork the decorator. And strict: true is baked in unconditionally: every RUT accepted by this decorator has passed the checksum, the canonical format check, and the placeholder block. That decision does not travel with the call site.

Using it in a DTO#

Drop @IsRut() into a DTO class alongside the standard class-validator decorators:

TypeScript
// src/customers/dto/create-customer.dto.ts
import { IsEmail, IsString, MinLength } from "class-validator";
import { IsRut } from "../../common/validators/is-rut.decorator";
 
export class CreateCustomerDto {
  @IsString()
  @MinLength(2)
  name!: string;
 
  @IsEmail()
  email!: string;
 
  @IsRut()
  rut!: string;
}

Enable ValidationPipe globally in main.ts:

TypeScript
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));

whitelist: true strips any property not declared in the DTO before execution reaches the controller. That means a caller cannot smuggle extra fields through the body — only the properties the schema knows about survive. Combined with @IsRut(), the pipeline rejects malformed RUTs and discards unrecognized properties in one step, before a single line of your own code runs.

What an invalid RUT looks like on the wire#

When ValidationPipe catches a constraint violation it returns a 400 (or 422 in some Nest versions) with a structured body. For the CreateCustomerDto above, a request carrying "rut": "11.111.111-1" — a placeholder that passes the raw checksum but fails strict — produces:

JSON
{
  "statusCode": 400,
  "message": [
    "rut must be a valid Chilean RUT"
  ],
  "error": "Bad Request"
}

The message array maps one entry per failing constraint. When there are multiple violations on the same field, each decorator contributes its own message to the array. The key the front end cares about is constraints.isRut inside the validation error object (accessible if you enable exceptionFactory with full error details): it identifies exactly which decorator rejected the value, so the client can display a field-specific error rather than a generic form failure.

This is the point where pairing server and client validation pays off. If the front end is using the RHF + Zod approach, both sides reject placeholder RUTs before the user sees a network round-trip. The server error is a second gate, not the first line of defense.

Pitfalls#

The most common mistake is adding a redundant validate() call inside the controller action or service method that produces a different error than the decorator. The decorator and the pipe reject the request before the controller body runs — if you also call validate(rut, { strict: true }) in service code and throw your own exception on failure, you now have two possible error shapes for the same problem. Defense in depth is fine, but keep the error message consistent, or the client will be unable to handle both shapes reliably.

The decorator only runs when the route binds the parameter as a class — specifically, @Body() dto: CreateCustomerDto. If you type the parameter as a plain interface (@Body() dto: ICreateCustomerDto), there is no class for class-validator to reflect on and the decorators are never evaluated. The request passes through unvalidated. The rule is simple: always use class types for validated DTOs.

Finally, @IsRut() confirms that the value is a structurally valid RUT, not that it is in the canonical stored form. Before writing the value to the database, call clean() or format() at the service layer to normalize it to a consistent shape. Storing whatever the user typed — 12345678-5, 12.345.678-5, or 12345678 5 — is how lookup inconsistencies start. See The right way to store Chilean RUTs in your database for the canonical storage pattern.

Further reading#

rut.ts

MIT License © 2026 Arrow Software