Skip to main content

Guide

class-validator vs TypeBox vs io-ts 2026

Compare class-validator, TypeBox, and io-ts for runtime TypeScript validation. Decorator-based OOP validation, JSON Schema generation, functional fp-ts.

·PkgPulse Team·
0

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

Error Message Quality and User-Facing Validation

The quality of validation error messages directly affects the developer experience of API consumers and, for public-facing APIs, the user experience of end users. Each library produces errors in a different format, and the ergonomics of transforming those errors into HTTP responses vary significantly.

class-validator's validate() returns an array of ValidationError objects, each with a property field (the field name), a constraints object (decorator names mapped to messages), and a children array for nested objects. The constraints object provides human-readable messages like "password must be longer than or equal to 8 characters" out of the box — messages that are good enough for many APIs to return directly. Custom decorator factories can override the default message with the message option. NestJS's ValidationPipe with transform: true automatically formats these into a 400 response body, making error formatting invisible for NestJS users.

TypeBox's compiled validator returns errors with a path field (a JSON Pointer like /email), an expected field (the expected schema constraint), and a value field (the actual invalid value). These are precise but not human-readable by default — "Expected string to match format 'email'" requires post-processing to produce user-friendly messages. For Fastify, the framework's built-in error formatter converts AJV-style errors to a standard error response, but the message text itself is schema-constraint language rather than user-facing copy.

io-ts's PathReporter.report(result) produces an array of strings like "Invalid value undefined supplied to: Package/weeklyDownloads: number" — useful for debugging but not appropriate for returning to API clients. Production io-ts applications typically use fold from fp-ts/Either to handle validation failures and map them to domain-specific error responses with custom message text. This extra mapping step is a meaningful ergonomic cost compared to class-validator's ready-to-use error messages, but it also prevents accidentally leaking schema internals in error responses.

OpenAPI and Documentation Generation

One of the least-discussed advantages of TypeBox is its role in automated API documentation. Because TypeBox schemas are valid JSON Schema objects, they drop directly into OpenAPI 3.x components.schemas definitions. Fastify's @fastify/swagger plugin reads the TypeBox schemas attached to route definitions and generates a complete OpenAPI specification at the /documentation/json endpoint — zero manual documentation maintenance. When your request and response schemas change, the OpenAPI spec updates automatically. This is a meaningful operational advantage: teams that write TypeBox schemas once get type-safe request handling, runtime validation, and API documentation from a single source of truth.

class-validator does not produce JSON Schema output natively. The class-validator-jsonschema package can generate JSON Schema from DTO classes with decorator metadata, but it requires maintaining reflect-metadata and the output is less reliable than TypeBox's native JSON Schema. For NestJS applications that want Swagger documentation, @nestjs/swagger provides @ApiProperty() decorators that annotate DTOs for documentation — meaning NestJS teams effectively maintain two sets of annotations, one for validation and one for documentation. This duplication is a known friction point in NestJS projects.

io-ts's codec model does not integrate with OpenAPI at all without custom serialization logic. For functional teams using io-ts in API layers, API documentation is typically maintained separately (in OpenAPI YAML files) and kept in sync manually. This is the highest maintenance burden of the three approaches and is a reason many io-ts-adjacent teams have migrated toward TypeBox or Zod (which has a zod-to-json-schema package) for public-facing APIs.

Performance at High Request Volume

Validation performance matters for high-throughput API endpoints. A naive implementation validates request bodies by creating class instances (class-validator) or running JSON Schema checks on every request. At thousands of requests per second, this CPU cost accumulates.

class-validator's validate() function creates a new class instance via class-transformer's plainToClass, runs all decorator validators, and collects errors. This involves reflection metadata lookups on every call. The overhead is typically 0.1-0.5ms per validation — negligible for most applications but measurable at 10K+ RPS. NestJS's ValidationPipe adds additional overhead from the pipe invocation chain.

TypeBox's TypeCompiler.Compile() generates a specialized validation function from the schema, avoiding the JSON Schema interpreter overhead on every call. The compiled validator is equivalent in performance to hand-written validation code — typically 5-20x faster than using ajv directly (which is already fast). For Fastify applications, TypeBox validation is integrated into Fastify's request pipeline at the C++ level via fast-json-stringify, making it one of the fastest validation options available in the Node.js ecosystem.

io-ts decoding is synchronous and reasonably fast for simple schemas, but the functional composition model means complex nested schemas involve multiple function calls per field. For deeply nested request bodies with many union types, io-ts can be measurably slower than TypeBox's compiled validator. The performance profile is similar to Zod before Zod's v4 rewrite — acceptable for most applications, but not the tool of choice for validation-heavy high-throughput services.

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 →

See also: swagger-ui-express vs @hono/zod-openapi vs fastify-swagger: OpenAPI for Node.js 2026 and cac vs meow vs arg 2026, acorn vs @babel/parser vs espree.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.