Skip to main content

class-validator vs TypeBox vs io-ts: Decorator vs Schema Runtime Validation (2026)

·PkgPulse Team

TL;DR

class-validator uses decorators on class properties — the natural choice for NestJS and OOP codebases where your data model is a class. TypeBox generates JSON Schema from TypeScript types at compile time — zero overhead, works with Fastify's built-in validation, and produces both TypeScript types and JSON Schema from the same definition. io-ts is the functional programming approach — codecs that encode and decode values with full fp-ts integration. For NestJS, class-validator is standard. For Fastify or JSON Schema needs, TypeBox. For fp-ts/functional codebases, io-ts. For most other projects, Zod is likely a better choice than all three.

Key Takeaways

  • class-validator: ~8M weekly downloads — decorator-based, NestJS standard, OOP style
  • @sinclair/typebox: ~15M weekly downloads — JSON Schema + TypeScript types, Fastify's schema language
  • io-ts: ~1.5M weekly downloads — functional codec-based validation, fp-ts ecosystem
  • class-validator is tightly coupled to NestJS and the decorator pattern — avoid outside that context
  • TypeBox is the JSON Schema-first option, perfect for Fastify or any schema-driven API
  • For greenfield projects without NestJS, Zod or Valibot are often better choices

PackageWeekly DownloadsApproachJSON SchemaFramework
class-validator~8MDecoratorsNestJS
@sinclair/typebox~15MType builderFastify, Any
io-ts~1.5MCodec/fpfp-ts

class-validator

class-validator validates class instances using decorators — the standard for NestJS.

Basic usage with NestJS

import { IsString, IsEmail, IsInt, Min, Max, IsOptional, IsEnum, ValidateNested } from "class-validator"
import { Type } from "class-transformer"
import { IsNotEmpty, Length } from "class-validator"

// DTO class (data transfer object):
export class CreateAlertDto {
  @IsString()
  @IsNotEmpty()
  @Length(1, 214)
  packageName: string

  @IsInt()
  @Min(1)
  @Max(100)
  threshold: number

  @IsEmail()
  email: string

  @IsEnum(["downloads_drop", "version_update", "security"])
  alertType: "downloads_drop" | "version_update" | "security"

  @IsOptional()
  @IsString()
  description?: string
}

// Nested validation:
export class PackageQueryDto {
  @ValidateNested({ each: true })  // Validates each item in array
  @Type(() => PackageFilterDto)
  filters: PackageFilterDto[]

  @IsInt()
  @Min(1)
  @Max(100)
  limit: number
}

// NestJS controller auto-validates via ValidationPipe:
// main.ts:
app.useGlobalPipes(new ValidationPipe({
  whitelist: true,           // Strip unknown properties
  forbidNonWhitelisted: true, // Throw on unknown properties
  transform: true,           // Auto-convert types (string "42" → number 42)
}))

// Controller:
@Controller("alerts")
export class AlertsController {
  @Post()
  create(@Body() dto: CreateAlertDto) {
    // dto is validated and typed — invalid requests never reach here
    return this.alertsService.create(dto)
  }
}

Custom validators

import {
  ValidatorConstraint,
  ValidatorConstraintInterface,
  ValidationArguments,
  registerDecorator,
} from "class-validator"

// Custom constraint:
@ValidatorConstraint({ name: "isNpmPackageName", async: true })
export class IsNpmPackageNameConstraint implements ValidatorConstraintInterface {
  async validate(name: string, args: ValidationArguments) {
    // Check if package exists in npm registry:
    const response = await fetch(`https://registry.npmjs.org/${name}`)
    return response.status === 200
  }

  defaultMessage({ value }: ValidationArguments) {
    return `Package "${value}" does not exist in npm registry`
  }
}

// Custom decorator factory:
export function IsNpmPackageName(validationOptions?: ValidationOptions) {
  return function (object: object, propertyName: string) {
    registerDecorator({
      target: object.constructor,
      propertyName,
      options: validationOptions,
      constraints: [],
      validator: IsNpmPackageNameConstraint,
    })
  }
}

// Use in DTO:
export class TrackPackageDto {
  @IsString()
  @IsNpmPackageName({ message: "Must be a valid npm package" })
  packageName: string
}

Manual validation (outside NestJS)

import { validate } from "class-validator"
import { plainToClass } from "class-transformer"

// Validate outside NestJS (e.g., in Express):
async function validateDto<T extends object>(
  DtoClass: new () => T,
  plain: unknown
): Promise<T> {
  const dto = plainToClass(DtoClass, plain)
  const errors = await validate(dto)

  if (errors.length > 0) {
    const messages = errors.flatMap((e) => Object.values(e.constraints ?? {}))
    throw new Error(`Validation failed: ${messages.join(", ")}`)
  }

  return dto
}

const dto = await validateDto(CreateAlertDto, req.body)

TypeBox

@sinclair/typebox builds JSON Schema and TypeScript types simultaneously:

Basic schema and type

import { Type, Static } from "@sinclair/typebox"

// Define schema (generates both TypeScript type AND JSON Schema):
const PackageSchema = Type.Object({
  name: Type.String({ minLength: 1, maxLength: 214 }),
  version: Type.String({ pattern: "^\\d+\\.\\d+\\.\\d+" }),
  weeklyDownloads: Type.Number({ minimum: 0 }),
  healthScore: Type.Number({ minimum: 0, maximum: 100 }),
  tags: Type.Array(Type.String(), { maxItems: 20 }),
  license: Type.Optional(Type.String()),
  publishedAt: Type.String({ format: "date-time" }),
})

// Extract TypeScript type from schema:
type Package = Static<typeof PackageSchema>
// Equivalent to:
// interface Package {
//   name: string
//   version: string
//   weeklyDownloads: number
//   healthScore: number
//   tags: string[]
//   license?: string
//   publishedAt: string
// }

Fastify integration (TypeBox is native)

import Fastify from "fastify"
import { Type, Static } from "@sinclair/typebox"

const app = Fastify()

const CreateAlertBody = Type.Object({
  packageName: Type.String({ minLength: 1 }),
  threshold: Type.Integer({ minimum: 1, maximum: 100 }),
  email: Type.String({ format: "email" }),
  alertType: Type.Union([
    Type.Literal("downloads_drop"),
    Type.Literal("version_update"),
    Type.Literal("security"),
  ]),
})

type CreateAlertBody = Static<typeof CreateAlertBody>

const AlertResponse = Type.Object({
  id: Type.String({ format: "uuid" }),
  createdAt: Type.String({ format: "date-time" }),
})

// Fastify uses JSON Schema for both validation AND OpenAPI generation:
app.post<{
  Body: CreateAlertBody
}>(
  "/alerts",
  {
    schema: {
      body: CreateAlertBody,    // Fastify validates automatically
      response: { 201: AlertResponse },
    },
  },
  async (request, reply) => {
    // request.body is typed as CreateAlertBody
    const { packageName, threshold, email, alertType } = request.body
    const alert = await createAlert({ packageName, threshold, email, alertType })
    return reply.status(201).send(alert)
  }
)

TypeBox compiler for maximum performance

import { TypeCompiler } from "@sinclair/typebox/compiler"
import { Type } from "@sinclair/typebox"

const PackageSchema = Type.Object({
  name: Type.String(),
  weeklyDownloads: Type.Number(),
  healthScore: Type.Number({ minimum: 0, maximum: 100 }),
})

// Compile schema once for reuse — 10-100x faster than ajv for repeated validation:
const validator = TypeCompiler.Compile(PackageSchema)

// Validate:
if (validator.Check(unknownData)) {
  // unknownData is narrowed to Package type here
  console.log(unknownData.name)  // TypeScript knows this is safe
}

// Get validation errors:
const errors = [...validator.Errors(unknownData)]
console.log(errors)
// [{ path: "/healthScore", message: "Expected number >= 0 and <= 100", value: 150 }]

io-ts

io-ts is the functional codec approach — decode untrusted input into typed values with fp-ts Result types:

Basic codecs

import * as t from "io-ts"
import { isRight } from "fp-ts/Either"

// Define codec:
const Package = t.type({
  name: t.string,
  weeklyDownloads: t.number,
  healthScore: t.number,
  tags: t.array(t.string),
})

// Extract TypeScript type:
type Package = t.TypeOf<typeof Package>

// Decode (validate + parse):
const result = Package.decode(untrustedData)

if (isRight(result)) {
  // Success — result.right is typed as Package
  console.log(result.right.name)
} else {
  // Failure — result.left contains PathReporter errors
  import { PathReporter } from "io-ts/PathReporter"
  console.error(PathReporter.report(result))
}

Composing codecs

import * as t from "io-ts"
import * as D from "io-ts/Decoder"

// Union types:
const AlertType = t.union([
  t.literal("downloads_drop"),
  t.literal("version_update"),
  t.literal("security"),
])

// Intersection types:
const BaseEntity = t.type({
  id: t.string,
  createdAt: t.string,
})

const Package = t.intersection([
  BaseEntity,
  t.type({
    name: t.string,
    weeklyDownloads: t.number,
  }),
])

// Optional fields:
const Alert = t.type({
  packageName: t.string,
  threshold: t.number,
  email: t.string,
  description: t.union([t.string, t.undefined]),
})

// From/to JSON (encode + decode):
const PackageFromString = new t.Type<Package, string, unknown>(
  "PackageFromString",
  (input): input is Package => typeof input === "object",
  (input, context) => {
    try {
      return t.success(JSON.parse(String(input)))
    } catch {
      return t.failure(input, context)
    }
  },
  (a) => JSON.stringify(a)
)

Feature Comparison

Featureclass-validatorTypeBoxio-ts
StyleOOP/DecoratorsFunctionalFunctional
TypeScript integration✅ Excellent✅ Excellent
JSON Schema output
fp-ts compatibility
NestJS integration✅ Built-in
Fastify integration✅ Native
Error messages✅ Descriptive✅ Path-based⚠️ PathReporter
Custom validators✅ Decorators✅ Custom type✅ Custom codec
Learning curveLow (NestJS devs)Low-MediumHigh (fp-ts)
Async validation

When to Use Each

Choose class-validator if:

  • Using NestJS — it's the standard and ValidationPipe integration is seamless
  • Your team prefers OOP and decorator-based patterns
  • You need async validators (e.g., checking if email exists in DB)
  • You're comfortable with class-transformer for plain object → class conversion

Choose TypeBox if:

  • Using Fastify — TypeBox is Fastify's native schema language
  • You need JSON Schema output for OpenAPI generation
  • Performance is critical — TypeBox's compiled validator is extremely fast
  • You want one definition that serves both TypeScript and runtime validation

Choose io-ts if:

  • Already using fp-ts extensively
  • You want functional error handling with Either/Result types
  • You need codec-based encode/decode for data transformation
  • Your team has strong functional programming background

Consider Zod or Valibot instead if:

  • Greenfield project without NestJS or Fastify
  • You want the cleanest API without framework coupling
  • TypeScript inference with minimal boilerplate is the priority

Methodology

Download data from npm registry (weekly average, February 2026). Feature comparison based on class-validator v0.14.x, @sinclair/typebox v0.33.x, and io-ts v2.x.

Compare validation and TypeScript packages on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.