Blogs / Solving TypeScript Runtime Validation Without Changing Your Code
August 23, 2025 • Matthew Duong • Engineering • 7 min read
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?
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
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:
I kept thinking: I already have TypeScript interfaces. Why can't I just use those?
Instead of changing how I write TypeScript, I built ts-runtime-validation to work with the TypeScript I already had. The approach is simple:
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:
File | Description | Optimizations |
---|---|---|
validation.schema.json | JSON Schema definitions for all your types | Minification with --minify |
SchemaDefinition.ts | TypeScript interface mapping schema paths to types | Tree-shaking ready imports |
isValidSchema.ts | Type guard helper with runtime validation | Lazy loading with --lazy-load |
ValidationType.ts | Centralized type exports | Individual exports or namespace |
My implementation leverages three battle-tested libraries:
The architecture is straightforward:
TypeScript Interfaces → JSON Schema → TypeScript Type Guards
This build-time generation approach means:
Modern Architecture: The tool uses a service-oriented architecture optimized for performance:
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:
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:
@minLength
, @maxLength
, @pattern
, @format
(email, uri, uuid, date-time, etc.)@minimum
, @maximum
, @exclusiveMinimum
, @exclusiveMaximum
, @multipleOf
@minItems
, @maxItems
, @uniqueItems
@minProperties
, @maxProperties
@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.
After analyzing the current landscape and running benchmarks, here's how ts-runtime-validation compares:
My approach uses AJV under the hood, which leads in raw validation performance8:
Since validators are pre-generated, the runtime impact is minimal9:
The key difference is when the work happens:
Through building and using this tool, I've learned when different approaches make sense:
✅ 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)
❌ 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
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:
--no-parallel
)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"
}
}
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.
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.
Build-time complexity is better than runtime complexity: Moving complexity to build time means simpler, faster production code.
JSDoc is underutilized: Using JSDoc for validation rules keeps the configuration close to the code and doesn't require learning a new API.
The TypeScript AST is powerful: Tools like ts-morph make it surprisingly easy to analyze and generate TypeScript code.
Performance optimizations matter: Features like incremental builds, parallel processing, and intelligent caching dramatically improve developer experience, especially for large projects.
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.