TypeScript stops at the door
The door = anywhere data comes in from outside, so network, DB, env vars, localStorage, queues, webhooks. Once bytes cross that line, tsc has zero idea what's actually in them, and most codebases just pretend it does.
The type assertion is a lie
This is the most common piece of TS ever written:
const res = await fetch('/api/user');
const user: User = await res.json();IDE happy, tsc happy, you happy, and it's a lie. res.json() returns Promise<any> and you just told the compiler trust me bro. If the backend renames email to emailAddress tomorrow, your frontend doesn't blow up at the boundary, it blows up 14 calls later in a render path with cannot read property of undefined, and you spend 2 hours figuring out where.
any is actually honest FYI, it tells you it doesn't know. The cast is any wearing a suit.
Just validate at the door
Once, then trust your types inside. Use Zod, Valibot, ArkType.
const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
createdAt: z.coerce.date(),
});
type User = z.infer<typeof UserSchema>;
const user = UserSchema.parse(await res.json());The type and the validator come from the same source so you literally cannot drift, and if the server sends garbage you find out at the call site with an error that actually says which field is wrong.
The DB is a boundary too
Your ORM types describe the schema the ORM thinks exists, not what prod actually has. Miss a migration, hand-edit a column, roll back code without rolling back the DB, and now your code reads what it thinks is a number and gets a string. JS being JS it'll happily do "5" + 1 = "51" and a customer gets charged $511 instead of $6.
Parse rows at the edge of the data layer too. Yeah it's annoying but it's correct.
The worst offenders
Grep your codebase, here's what to look for:
JSON.parsewith no schema, because every localStorage read, every cookie, every webhook body isany, and some of those are user-editable.as User,as unknown as Whatever,data!on anything from outside, all the same lie.process.env.PORTused directly, since it'sstring | undefined,parseInt(undefined)isNaN, your server binds to port NaN, and you find out in prod.
Env vars are the easiest fix, just parse once at startup:
const Env = z.object({
PORT: z.coerce.number().int().positive(),
DATABASE_URL: z.string().url(),
STRIPE_SECRET: z.string().startsWith('sk_'),
});
const env = Env.parse(process.env);5 lines and it catches a whole category of "wait why is this undefined in prod" at 2am.
Use unknown not any
unknown is any with a seatbelt, you can't do anything with it without narrowing first, which is the point.
// any: compiler shrugs, prod crashes
async function fetchUser(): Promise<any> {
const res = await fetch('/api/user');
return res.json();
}
const u = await fetchUser();
u.emial.toLowerCase(); // tsc says nothing, explodes in prod// unknown: compiler forces you to deal with it
async function fetchUser(): Promise<unknown> {
const res = await fetch('/api/user');
return res.json();
}
const raw = await fetchUser();
raw.email; // tsc: 'raw' is of type 'unknown'. nope.
const user = UserSchema.parse(raw); // now its a real User
user.email.toLowerCase(); // safeIf your codebase has more as casts than unknown declarations that's a smell, flip it.
tldr
Outside the walls you're writing JavaScript, you should act like it.