Blogs / Solving TypeScript Runtime Validation Without Changing Your Code

Solving TypeScript Runtime Validation Without Changing Your Code

August 23, 2025 • Matthew Duong • Engineering • 7 min read

Solving TypeScript Runtime Validation Without Changing Your Code

Why I Built ts-runtime-validation

After years of wrestling with TypeScript's disappearing type safety at runtime, I built ts-runtime-validation to solve a problem that kept frustrating me: why should I rewrite all my TypeScript interfaces just to validate data at runtime?

Why? What is the problem?

"TypeScript's compile-time safety disappears at runtime, leaving your application vulnerable to invalid data." Like many TypeScript developers, I've been burned by the false sense of security that TypeScript's compile-time types provide. Your perfectly typed interfaces mean nothing when parsing API responses, reading user input, or loading configuration files. The data you receive might be completely different from what TypeScript expects, leading to runtime errors that TypeScript couldn't prevent.

interface User {
    id: string;
    email: string;
    age: number;
}

// TypeScript is happy, but this is a lie
const user: User = JSON.parse(userDataFromAPI);
console.log(user.email.toLowerCase()); // 💥 Runtime error if email is undefined

Why Existing Solutions Frustrated Me

"Skip the rewriting: ts-runtime-validation generates validators directly from your existing TypeScript interfaces"

When I first encountered this problem, I looked at the popular runtime validation libraries. Zod dominates the space with 39,487 GitHub stars and 39.6 million weekly downloads12. Yup, io-ts, and others offer similar approaches. They're all excellent tools, but they share a fundamental approach that frustrated me: you have to redefine your types using their APIs.

// With Zod - you define the schema, not the type
const UserSchema = z.object({
    id: z.string(),
    email: z.string(),
    age: z.number(),
});

type User = z.infer<typeof UserSchema>; // Type is derived from schema

This meant I'd have to:

  • Rewrite hundreds of existing TypeScript interfaces across multiple projects
  • Learn each library's schema definition API
  • Maintain schema definitions alongside (or instead of) TypeScript types
  • Add runtime dependencies that increase bundle size (Zod is ~12KB gzipped3, Yup is ~60KB4)

I kept thinking: I already have TypeScript interfaces. Why can't I just use those?

My Approach

Instead of changing how I write TypeScript, I built ts-runtime-validation to work with the TypeScript I already had. The approach is simple:

  1. Keep your existing TypeScript interfaces unchanged
  2. Add validation rules using JSDoc comments (optional)
  3. Generate validators at build time
  4. Get type-safe runtime validation with zero runtime overhead

Here's how it works in practice:

// user.jsonschema.ts - Your existing TypeScript remains unchanged

export interface User {
    /**
     * MongoDB ObjectId
     * @pattern ^[a-fA-F0-9]{24}$
     */
    id: string;

    /**
     * @format email
     */
    email: string;

    /**
     * @minimum 0
     * @maximum 150
     */
    age: number;
}

Run the generator:

npx ts-runtime-validation

And use the generated type-safe validator:

import { isValidSchema } from "./.ts-runtime-validation/isValidSchema";

const userData = await fetch("/api/user").then((r) => r.json());

if (isValidSchema(userData, "#/definitions/User")) {
    // userData is now typed as User
    console.log(userData.email.toLowerCase()); // Safe! TypeScript knows email exists
} else {
    console.error("Invalid user data");
}

Generated Files: The tool creates optimized files in your output directory:

FileDescriptionOptimizations
validation.schema.jsonJSON Schema definitions for all your typesMinification with --minify
SchemaDefinition.tsTypeScript interface mapping schema paths to typesTree-shaking ready imports
isValidSchema.tsType guard helper with runtime validationLazy loading with --lazy-load
ValidationType.tsCentralized type exportsIndividual exports or namespace

The Architecture

My implementation leverages three battle-tested libraries:

  1. ts-json-schema-generator: Parses TypeScript AST and converts interfaces to JSON Schema5
  2. ts-morph: Provides TypeScript compiler API access for analyzing your code6
  3. AJV: The fastest JSON Schema validator, processing objects in ~42 nanoseconds7

The architecture is straightforward:

TypeScript Interfaces → JSON Schema → TypeScript Type Guards

"Move validation complexity to build time for zero runtime overhead"

This build-time generation approach means:

  • No runtime schema construction overhead
  • Minimal runtime dependencies (just AJV for validation)
  • Your production bundle only includes the generated validators, not the generation logic

Modern Architecture: The tool uses a service-oriented architecture optimized for performance:

  • FileDiscovery: Intelligent file system operations with caching
  • SchemaProcessor: TypeScript AST processing with parallel execution
  • CodeGenerator: Optimized TypeScript file generation
  • SchemaWriter: Efficient file writing with minification support
  • ProgressReporter: User-friendly progress tracking for long operations

Real-World Usage: Validating Fields in DS160.io

"Validating fields in production: ts-runtime-validation powers DS160.io's complex form validation"

I've been dogfooding ts-runtime-validation in my visa application tool, DS160.io. The DS-160 form has over 500 fields with complex validation rules. Procedure:

  1. Defined all form fields as TypeScript interfaces
  2. Added validation constraints using JSDoc annotations
  3. Generated validators for form submissions
  4. Achieved type-safe validation without writing any schema code

Here's a real example from the codebase:

export interface IPassportInfo {
    /**
     * @pattern ^[A-Z0-9]{6,9}$
     */
    passportNumber: string;

    /**
     * @format date
     */
    issuanceDate: string;

    /**
     * @format date
     */
    expirationDate: string;

    /**
     * ISO 3166-1 alpha-2 country code
     * @pattern ^[A-Z]{2}$
     */
    issuanceCountry: string;
}

The tool now supports comprehensive JSDoc annotations for validation rules:

Supported Annotations:

  • Strings: @minLength, @maxLength, @pattern, @format (email, uri, uuid, date-time, etc.)
  • Numbers: @minimum, @maximum, @exclusiveMinimum, @exclusiveMaximum, @multipleOf
  • Arrays: @minItems, @maxItems, @uniqueItems
  • Objects: @minProperties, @maxProperties
  • General: @description, @default, @examples
// Advanced validation example
export interface IAdvancedProduct {
    /**
     * Product tags
     * @minItems 1
     * @maxItems 10
     * @uniqueItems true
     */
    tags: string[];

    /**
     * Product price in cents
     * @minimum 0
     * @maximum 1000000
     * @multipleOf 1
     */
    price: number;

    /**
     * Product website
     * @format uri
     * @pattern ^https://
     */
    website?: string;
}

The generated validators catch invalid data before it reaches the backend, providing better user experience and reducing server load.

Performance: How My Approach Compares

After analyzing the current landscape and running benchmarks, here's how ts-runtime-validation compares:

Validation Speed

"AJV-powered validation delivers 5x faster performance than traditional schema libraries" My approach uses AJV under the hood, which leads in raw validation performance8:

  • AJV (my approach): ~42 nanoseconds per validation
  • Zod: 200-300 nanoseconds (5x slower)
  • Yup: 500+ nanoseconds (12x slower)

Bundle Size

Since validators are pre-generated, the runtime impact is minimal9:

  • ts-runtime-validation: 12KB (AJV only)
  • Zod: 12KB (after recent optimizations)10
  • Yup: 60KB
  • io-ts: 35KB

Build-Time vs Runtime

The key difference is when the work happens:

  • My approach: Schema construction happens at build time (0ms runtime overhead)
  • Traditional libraries: Schema construction happens at runtime (12-18ms initial setup)11

When to Use ts-runtime-validation vs Other Libraries

Through building and using this tool, I've learned when different approaches make sense:

Use ts-runtime-validation when:

✅ You have existing TypeScript interfaces you don't want to rewrite
✅ You prefer build-time code generation over runtime libraries
✅ You want minimal runtime dependencies
✅ Your types don't change frequently at runtime
✅ You're already using a build step (TypeScript compilation)

Consider other libraries when:

❌ You need dynamic schema composition at runtime
❌ You prefer defining schemas first and deriving types
❌ You need complex runtime transformations or coercions
❌ You're in a JavaScript-only environment

Advanced CLI Options and Performance Features

The tool now includes comprehensive performance optimizations and developer experience improvements:

Performance Options:

# Development workflow with caching and progress
ts-runtime-validation --cache --progress --verbose

# Production build with optimizations  
ts-runtime-validation --cache --minify --tree-shaking

# Large projects with memory efficiency
ts-runtime-validation --cache --lazy-load --progress

Key Performance Features:

  • Incremental builds - MD5-based file change detection with caching system
  • Parallel processing - Concurrent file processing (can be disabled with --no-parallel)
  • Bundle optimization - Tree-shaking friendly exports and minified output
  • Memory efficiency - Lazy-loaded validators for large projects
  • Progress tracking - Visual feedback for long-running operations

The caching system stores MD5 hashes in .ts-runtime-validation-cache/ and only processes modified files, providing significant speedup for large codebases.

Development Workflows:

{
    "scripts": {
        "generate-types": "ts-runtime-validation --cache --progress",
        "generate-types:watch": "nodemon --watch 'src/**/*.jsonschema.ts' --exec 'yarn generate-types'",
        "generate-types:dev": "ts-runtime-validation --cache --verbose --progress",
        "generate-types:prod": "ts-runtime-validation --cache --minify --tree-shaking"
    }
}

What alternatives are available

After researching the ecosystem extensively, I've found that each approach has its place:

Zod excels at developer experience with its TypeScript-first design and automatic type inference through z.infer<>. It's become the de facto standard for modern TypeScript projects, especially when using tRPC12.

Valibot takes a modular approach, achieving bundles as small as 513 bytes through aggressive tree-shaking. For client-side applications where every byte counts, it can be 90% smaller than Zod13.

TypeBox generates standard JSON Schema like my approach but defines schemas at runtime. It's becoming popular for OpenAPI documentation and achieves excellent performance through JIT compilation14.

Typia represents the cutting edge, using TypeScript transformers to generate validation code at compile time. It claims 20,000x performance improvements over traditional validators15.

My goals and learnings from this experience

  1. Developer ergonomics matter more than features: The main value isn't that it validates data (many tools do that), but that it works with existing code without changes.

  2. Build-time complexity is better than runtime complexity: Moving complexity to build time means simpler, faster production code.

  3. JSDoc is underutilized: Using JSDoc for validation rules keeps the configuration close to the code and doesn't require learning a new API.

  4. The TypeScript AST is powerful: Tools like ts-morph make it surprisingly easy to analyze and generate TypeScript code.

  5. Performance optimizations matter: Features like incremental builds, parallel processing, and intelligent caching dramatically improve developer experience, especially for large projects.

Current Limitations

  • No duplicate type names - Each interface/type must have a unique name across all schema files
  • TypeScript-only constructs - Some advanced TypeScript features (like conditional types) may not be fully supported
  • Circular references - Limited support for circular type references
  • Build step requirement - Must regenerate when types change (though caching minimizes this impact)

Try It Yourself

Give ts-runtime-validation a try:

# Using yarn (recommended)
yarn add --dev ts-runtime-validation
yarn add ajv  # Required peer dependency

# Using npm
npm install --save-dev ts-runtime-validation
npm install ajv  # Required peer dependency

Check out the complete documentation with interactive examples and quick start guide, or visit the GitHub repository for the source code.


References


  1. Zod GitHub Repository - 39,487 stars as of January 2025
  2. NPM Weekly Downloads Comparison - Download statistics for validation libraries
  3. Zod Bundle Size Analysis - Bundle size metrics
  4. Validation Library Bundle Sizes - Comprehensive size comparison
  5. ts-json-schema-generator - TypeScript to JSON Schema converter
  6. ts-morph Documentation - TypeScript compiler API wrapper
  7. Node.js Validators Benchmark - Performance comparison
  8. AJV vs Zod Performance Analysis - Runtime validator comparison
  9. Schema Validation Libraries Comparison - AJV, Joi, Yup, and Zod analysis
  10. Zod Performance Improvements - Recent optimization details
  11. TypeScript Runtime Validators Performance - Type-checking performance analysis
  12. Zod Documentation - Official Zod documentation
  13. Introducing Valibot - Valibot's modular approach
  14. TypeBox vs Zod Comparison - Why Val Town uses TypeBox
  15. Typia Performance Claims - Compile-time validation benchmarks
© 2023-2024 Matthew Duong