Thoughts

TypeScript stops at the door

· 3 min read

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.parse with no schema, because every localStorage read, every cookie, every webhook body is any, and some of those are user-editable.
  • as User, as unknown as Whatever, data! on anything from outside, all the same lie.
  • process.env.PORT used directly, since it's string | undefined, parseInt(undefined) is NaN, 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(); // safe

If 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.