DEV Community

Anas Sheikh
Anas Sheikh

Posted on

Your TypeScript Is Lying to You, and These 5 Places Hide Real Bugs

TypeScript's most dangerous keyword is two letters long.

as does not convert anything. It does not check anything. It tells the compiler "trust me", and the compiler does. If you are wrong, the error moves from your editor to your users.

Here are five places I keep finding it in real codebases, each one compiling cleanly and each one a crash waiting for the right input.

1. Casting an API Response

const res = await fetch('/api/users/1');
const user = (await res.json()) as User;

console.log(user.profile.avatarUrl);
Enter fullscreen mode Exit fullscreen mode

res.json() returns Promise<any>. Casting any to User is the compiler equivalent of a handshake agreement. If the backend renames a field, makes profile optional, or returns an error object with a 200 status, this line still compiles and the crash happens three functions later with a message that has nothing to do with the cause.

What to write instead: validate the data at the boundary.

import { z } from 'zod';

const UserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  profile: z.object({ avatarUrl: z.string().url().nullable() }),
});

type User = z.infer<typeof UserSchema>;

const user = UserSchema.parse(await res.json());
Enter fullscreen mode Exit fullscreen mode

Now the type is derived from a check that actually ran. If the shape is wrong, you get a clear error at the exact line where bad data entered your app.

2. The Non-Null Assertion on Environment Variables

const client = new MongoClient(process.env.DATABASE_URL!);
Enter fullscreen mode Exit fullscreen mode

process.env.X is typed string | undefined, which is honest. The ! deletes the undefined from the type and nothing else. When the variable is missing in a new environment, you get a confusing failure from deep inside a library instead of "DATABASE_URL is missing".

function requireEnv(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing environment variable: ${name}`);
  return value;
}

const client = new MongoClient(requireEnv('DATABASE_URL'));
Enter fullscreen mode Exit fullscreen mode

The app now fails at startup, with the variable name in the message. That is the cheapest place to find out.

3. The Non-Null Assertion on find()

const user = users.find((u) => u.id === id)!;
sendEmail(user.email);
Enter fullscreen mode Exit fullscreen mode

find returns undefined when nothing matches, and "nothing matches" is exactly the case your tests forgot. The ! makes the compiler stop warning you about the one scenario worth warning about.

const user = users.find((u) => u.id === id);
if (!user) throw new Error(`User ${id} not found`);

sendEmail(user.email);
Enter fullscreen mode Exit fullscreen mode

After the check, TypeScript narrows user to a real object on its own. No assertion required.

4. Casting Object.keys

type Settings = { theme: string; lang: string };

function logSettings(s: Settings) {
  (Object.keys(s) as (keyof Settings)[]).forEach((k) => {
    console.log(s[k].toUpperCase());
  });
}

const fromApi = { theme: 'dark', lang: 'en', retries: 3 };
logSettings(fromApi);
Enter fullscreen mode Exit fullscreen mode

This compiles. TypeScript types are structural, so an object with extra properties is still a valid Settings when it arrives through a variable. Object.keys returns plain string[] for exactly this reason, and the cast to keyof Settings overrides that protection. At runtime retries is a number, and toUpperCase is not a function.

const SETTING_KEYS = ['theme', 'lang'] as const;

function logSettings(s: Settings) {
  SETTING_KEYS.forEach((k) => console.log(s[k].toUpperCase()));
}
Enter fullscreen mode Exit fullscreen mode

Iterate over a list of keys you control, not over whatever the object happens to contain.

5. Casting the Caught Error

try {
  await saveProfile(data);
} catch (e) {
  toast((e as Error).message);
}
Enter fullscreen mode Exit fullscreen mode

In JavaScript you can throw anything: a string, a number, undefined, a plain object. Libraries do it. With strict on, TypeScript types e as unknown to keep you honest, and as Error is the line that undoes it. If something throws a string, .message is undefined and your toast says "undefined".

catch (e) {
  const message = e instanceof Error ? e.message : 'Something went wrong';
  toast(message);
}
Enter fullscreen mode Exit fullscreen mode

A Bonus Lie That Lives in Your Config

const items: Item[] = [];
const first = items[0];
first.name; // typed as Item, crashes at runtime
Enter fullscreen mode Exit fullscreen mode

Array index access is typed as Item, not Item | undefined, because the compiler cannot know the length. One line in tsconfig.json fixes it:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true
  }
}
Enter fullscreen mode Exit fullscreen mode

This flag is not part of strict, so most projects never turn it on. It adds a few extra checks, and every one of them is a real case where the array could have been empty.

The Rule

A cast is a promise you make to the compiler that it cannot verify. Data from outside your program needs a runtime check, not a type assertion.

An easy audit: search your project for as and for !. and !;. For each hit, ask what happens when the assumption is false. Casts on your own literals are usually fine. Casts on network responses, environment variables, search results and caught errors are where the bugs live.

I keep these checks in the shared lib/ folder of the TypeScript-first templates on pixelanas.com, so a missing environment variable or a changed API shape fails loudly on the first request instead of silently in production.

Get the templates: https://pixelanas.gumroad.com

How many as casts does your project have, and how many of them are on data you do not control? Drop your count in the comments.


Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751

Top comments (0)