🚀 Executive Summary
TL;DR: The article addresses the critical problem of TypeScript types drifting from runtime validation logic, which can lead to production bugs due to the compile-time-only nature of types. It advocates for establishing a single source of truth, primarily by inferring TypeScript types directly from a runtime validation schema using libraries like Zod, or by generating schemas from existing types for legacy systems.
🎯 Key Takeaways
- TypeScript types are compile-time constructs and are erased during compilation, meaning they cannot be used directly for runtime data validation.
- Manually synchronizing separate TypeScript types and runtime validation schemas (e.g., Joi) is highly error-prone and a common source of production bugs.
- The ‘schema-first’ approach, exemplified by Zod, is the modern standard: define a single runtime validation schema and then infer the static TypeScript type from it, ensuring they always match.
- For legacy codebases with existing TypeScript interfaces, tools like `ts-to-zod` can generate runtime validation schemas, providing a transitional strategy to add runtime safety without full rewrites.
Stop writing validation logic that duplicates your TypeScript types. Learn to derive runtime validation directly from a single source of truth to prevent bugs, reduce boilerplate, and keep your codebase DRY (Don’t Repeat Yourself).
Stop Writing the Same Logic Twice: Generating Validation from Your Types
It was 2:17 AM. My phone buzzed on the nightstand with the fury of a PagerDuty alert tied to our core authentication service. A new deployment had gone out hours earlier, green across the board. But now, our downstream user-profile service was crashing in a loop. After 30 minutes of frantic log diving, I found it: users were being created with null email addresses. The frontend TypeScript type clearly defined `email: string`, but the backend validation schema, the one running in the Lambda authorizer, had been ‘helpfully’ updated by a junior dev to allow `string | null` during a refactor. The type and the validator had drifted apart, and that tiny gap was big enough to crash a production service.
The Problem We’ve All Faced
This is a classic headache in the TypeScript world. We spend all this time creating beautifully explicit types for our data structures, giving us amazing autocompletion and compile-time safety. We have our User, Product, and Order types perfectly defined. Then, when it’s time to validate an incoming API request, we have to write a completely separate piece of logic—a Joi schema, a Yup validator, or a bunch of manual `if` statements—that essentially describes the exact same shape. We create two sources of truth, and keeping them in sync is a manual, error-prone chore that, as my 2 AM incident proves, can have real consequences.
Why This Happens: The Compile-Time vs. Runtime Divide
The root of the issue is simple: TypeScript types don’t exist at runtime. They are a developer tool. When you compile your `.ts` files to `.js`, all the type annotations are erased. That `User` interface is gone, vanished. Your JavaScript code has no idea what a `User` is supposed to look like. This means you can’t do if (data instanceof User). Runtime validation requires a different tool—one that actually exists and can run in the Node.js or browser environment to inspect the live data object.
So, how do we bridge this gap without losing our minds? Here are a few approaches I’ve used in the wild, from the quick fix to the right fix.
Solution 1: The “You’re Probably Already Doing This” Manual Sync
This is the most common and most fragile approach. You define your type, then you define your validator separately, and you just pray you remember to update both. It’s the default for a reason—it’s intuitive, but it’s also where the bugs creep in.
Look at this all-too-common scenario:
// Source of Truth #1: The Type (in user.types.ts)
export interface UserProfile {
userId: string;
displayName: string;
email: string; // The type says this is required
}
// Source of Truth #2: The Validator (in user.validator.ts)
import Joi from 'joi';
export const userProfileSchema = Joi.object({
userId: Joi.string().guid().required(),
displayName: Joi.string().min(3).required(),
email: Joi.string().email(), // Whoops! We forgot .required() here.
});
Warning: This path is paved with late-night PagerDuty alerts. A simple oversight, a missed `required()` call, and your data integrity is compromised. You are relying solely on developer discipline, which is not a scalable strategy.
Solution 2: The Modern Standard – Inferring Types from a Schema
This is the approach we’ve standardized on for all new services at TechResolve. Instead of two sources of truth, you create one: the validation schema. You then *infer* the static TypeScript type directly from that schema. This makes it impossible for them to drift apart. My favorite library for this is Zod.
With Zod, you define the schema, which serves as your runtime validator, and get the TypeScript type for free.
// The SINGLE Source of Truth (in user.schema.ts)
import { z } from 'zod';
// 1. Define the runtime validator
export const UserProfileSchema = z.object({
userId: z.string().uuid("Invalid UUID format"),
displayName: z.string().min(3, "Display name must be at least 3 characters"),
email: z.string().email("Invalid email address"),
});
// 2. Infer the static TypeScript type from the schema
export type UserProfile = z.infer<typeof UserProfileSchema>;
/*
The inferred UserProfile type is now:
{
userId: string;
displayName: string;
email: string;
}
It will ALWAYS match the schema!
*/
// 3. Use it for validation at runtime
function handleRequest(data: unknown) {
const validationResult = UserProfileSchema.safeParse(data);
if (!validationResult.success) {
console.error("Validation failed:", validationResult.error);
return; // Handle error
}
// Now you can safely use the data, and TypeScript knows its shape!
const validProfile: UserProfile = validationResult.data;
console.log(`Welcome, ${validProfile.displayName}!`);
}
Pro Tip: This schema-first approach is the gold standard. It makes your code more robust and eliminates an entire category of bugs. If you’re starting a new project, start here. Full stop.
Solution 3: The ‘Legacy Rescue’ – Generating Schemas from Types
What if you’re working on a massive, five-year-old codebase? You have hundreds of hand-written TypeScript interfaces, and telling your manager you need to rewrite them all as Zod schemas is a non-starter. In this situation, you can go the other way: generate a validation schema from an existing type.
This is a great transitional strategy. A tool like ts-to-zod can be a lifesaver. It’s a CLI tool that reads your TypeScript files and spits out Zod schemas that match your interfaces.
How it Works:
- You have your legacy type definitions.
- You run the generator tool in your terminal.
- It generates the Zod schema for you.
// file: src/legacy/types.ts
export interface LegacyAsset {
id: number;
name: string;
tags?: string[];
}
npx ts-to-zod src/legacy/types.ts src/generated/schemas.ts
// file: src/generated/schemas.ts (AUTO-GENERATED)
import { z } from "zod";
export const legacyAssetSchema = z.object({
id: z.number(),
name: z.string(),
tags: z.array(z.string()).optional(),
});
Now you have a runtime validator you can import and use immediately, without having to manually rewrite anything. This is perfect for incrementally adding runtime safety to an old project. However, the long-term goal should still be to move to a schema-first model (Solution 2) for new development.
Which Approach Should You Use?
Here’s my breakdown of when to use each strategy:
| Approach | Best For | Pros | Cons |
|---|---|---|---|
| 1. Manual Sync | Quick prototypes, small scripts | Simple to understand initially | Extremely error-prone, creates drift |
| 2. Infer Type from Schema | All new projects | Single source of truth, robust, type-safe | Slight learning curve for Zod/etc. |
| 3. Generate Schema from Type | Modernizing legacy codebases | Quickly add validation to existing types | Still two sources of truth; a temporary fix |
At the end of the day, our job is to build resilient systems. Eliminating drift between what we think our data looks like (the type) and what it actually looks like at runtime (the validator) is a huge step toward that goal. Don’t let a simple sync issue be the reason you’re waking up at 2 AM.
🤖 Frequently Asked Questions
âť“ Why can’t TypeScript types be used directly for runtime validation?
TypeScript types are compile-time constructs that are erased when `.ts` files are compiled to `.js`, meaning they do not exist at runtime to inspect live data objects.
âť“ How does the ‘infer type from schema’ approach (e.g., Zod) compare to manual type-validator synchronization?
The ‘infer type from schema’ approach creates a single source of truth, making it impossible for the static type and runtime validator to drift apart, unlike manual synchronization which is error-prone and relies on developer discipline.
âť“ What is a common pitfall when implementing runtime validation in TypeScript projects?
A common pitfall is maintaining two separate sources of truth – one for TypeScript types and another for runtime validation schemas – leading to inconsistencies and bugs when one is updated without the other. The solution is to adopt a single-source-of-truth strategy, ideally schema-first.
Leave a Reply