class-validator vs TypeBox vs io-ts: Decorator vs Schema Runtime Validation (2026)
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
Download Trends
| Package | Weekly Downloads | Approach | JSON Schema | Framework |
|---|---|---|---|---|
class-validator | ~8M | Decorators | ❌ | NestJS |
@sinclair/typebox | ~15M | Type builder | ✅ | Fastify, Any |
io-ts | ~1.5M | Codec/fp | ❌ | fp-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
| Feature | class-validator | TypeBox | io-ts |
|---|---|---|---|
| Style | OOP/Decorators | Functional | Functional |
| 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 curve | Low (NestJS devs) | Low-Medium | High (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-transformerfor 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.