DEV Community

Ty North
Ty North

Posted on

You Don't Need TypeScript. You Need Runtime Guarantees.

Hot take: We're using the wrong tool for the job. Your TypeScript types are giving you a false sense of security where it matters most.

Let's be honest. We adopted TypeScript for safety. And for the code you write and control, it gets the job done.

But where do the most dangerous bugs originate? From the outside world: API responses, user input, webhook payloads. Then here comes TypeScript to do absolutely nothing.

That's right. TypeScript, a compile-time tool, is completely silent when your app receives malformed JSON at runtime. Your beautiful type User = { id: number; } offers zero resistance when an API mistakenly sends { "id": "user-123" }. The code compiles, your app runs, and then it crashes.

This fundamental gap forces us into a clumsy, defensive workflow that I call the "Two-Sources-of-Truth Trap."

If writing the same logic twice doesn't make you itch, then you should get that checked out.

In the meantime, let's look at a typical setup that lures us into the two sources of truth trap.

The Problem in Code: The Two-Sources-of-Truth Trap

To safely handle data from the outside world, you have to define your data shape in at least two places.

The Standard TypeScript Way

1. First, you define the TypeScript type for your editor and compiler:

// src/types.ts
export type User = {
  id: number;
  username: string;
  email: string;
  status: "active" | "inactive" | "pending";
};
Enter fullscreen mode Exit fullscreen mode

2. Then, you define a near-identical schema for runtime validation using a library like Zod:

// src/schemas.ts
import { z } from "zod";

export const UserSchema = z.object({
  id: z.number(),
  username: z.string().min(3),
  email: z.string().email(),
  status: z.enum(["active", "inactive", "pending"]),
});
Enter fullscreen mode Exit fullscreen mode

3. Finally, you use both:

import { UserSchema, type User } from './schemas';

async function getUser(userId: number): Promise<User> {
  const data = await fetch(`/api/user/${userId}`).then(res => res.json());
  const validatedUser = UserSchema.parse(data);
  return validatedUser;
}
Enter fullscreen mode Exit fullscreen mode

This works, but it's boilerplate. It's two sources of truth. Boooo....


The Solution: A Single Source of Truth

I decided to reject this duplication, as should y'all. So, I built AssertScript, a tool based on a simple philosophy: your runtime validation schema should be the single source of truth, from which everything else is generated.

The AssertScript Way

1. The Single Definition:
This types.json file is the one and only contract for your data. It defines the types and the rules.

// types.json
"User": {
  "id": { "type": "number" },
  "username": { "type": "string", "minLength": 3 },
  "email": { "type": "string", "pattern": "^.+@.+\\..+$" },
  "status": { "type": "string", "enum": ["active", "inactive", "pending"] }
}
Enter fullscreen mode Exit fullscreen mode

2. The Generation Step (Done once, or on save):
You run a command: npm run generate:dts. This produces:

  • A dependency-free validators.js file for runtime enforcement.
  • A validators.d.ts file for editor intelligence.

3. The Usage (Lean and Zero-Dependency):

import { validateUser } from './validators.js';
// The .d.ts file gives us full autocompletion below!

async function getUser(userId) {
  const data = await fetch(`/api/user/${userId}`).then(res => res.json());

  // One function call to validate everything at runtime. No extra dependencies.
  validateUser(data);

  // If this line is reached, data is GUARANTEED to have the correct shape.
  // We can use it with 100% confidence and first-class editor support.
  return data;
}
Enter fullscreen mode Exit fullscreen mode

There is no duplication. No production dependencies. The runtime validation is the source of truth, and the editor support is a free, generated byproduct.

TLDR: Stop Asking Your Compiler to Do a Validator's Job

We've been misapplying a great tool. TypeScript is fantastic for ensuring the code you write is internally consistent. But it is, by design, the wrong tool for guaranteeing the integrity of data that comes from the outside world.

Stop maintaining two sources of truth for your data models. Stop adding heavy dependencies to do a job that can be handled with a simple, generated function.

Define your runtime contracts once, generate your guards, and build safer, leaner, and more reliable applications.


The full source code for the AssertScript generator is available on my GitHub: AssertScript Repo

What do you think? Is the "TypeScript + Zod" pattern the best we can do, or is it time to rethink our approach to data validation?

more to come...

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.