TypeScript Is the Default Now — So Why Does Your Codebase Still Feel Untyped?

You migrated to TypeScript. You renamed the files. You installed the types. You pat yourself on the back.
Then six months later, a senior dev reviews your PR and politely points out that your codebase is basically JavaScript with extra steps. any scattered like confetti. Type assertions used as a fire extinguisher. Interfaces that technically exist but never do any work.
Sound familiar? Let’s fix that — without turning your codebase into an academic exercise.
The problem: TypeScript as a costume
Most devs adopt TypeScript syntactically before they adopt it structurally. They learn the keywords before they learn the thinking behind them.
The result is code that compiles cleanly but catches zero bugs — which is the whole point TypeScript was hired for.
Here’s the most common offenders.
Offender #1 — any is a type system opt-out
// You wrote this and moved on
function processData(input: any) {
return input.value * 2;
}
any tells TypeScript: “trust me, I got this.” TypeScript nods politely and stops watching. You’ve turned off the smoke alarm because you were tired of the beeping.
Fix it with unknown — it forces you to actually check before you use:
function processData(input: unknown): number {
if (typeof input === "object" && input !== null && "value" in input) {
const val = (input as { value: number }).value;
return val * 2;
}
throw new Error("Invalid input shape");
}
More verbose? Yes. But now the compiler is working with you, not looking the other way.
// eslint-disable-next-line @typescript-eslint/no-explicit-any with a comment explaining why — future you will thank present you.Offender #2 — type assertions instead of type guards
// This compiles. It also lies.
const user = response.data as User;
console.log(user.email.toLowerCase()); // 💥 at runtime if email is undefined
as SomeType is you asserting to the compiler: “this IS that type.” The compiler believes you unconditionally. It’s not a cast. It’s a pinky promise.
Fix it with a type guard:
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"email" in value &&
typeof (value as Record<string, unknown>).email === "string"
);
}
if (isUser(response.data)) {
console.log(response.data.email.toLowerCase()); // safe
}
Now TypeScript knows the shape is validated, not assumed. The value is User return type is called a type predicate — it narrows the type inside the if block automatically.
Offender #3 — interfaces that describe nothing
interface Config {
options: any;
data: object;
callback: Function;
}
Three fields, zero information. any, object, and Function are all effectively untyped — they satisfy the compiler without constraining anything.
Make your interfaces earn their keep:
interface Config {
options: {
timeout: number;
retries: number;
verbose?: boolean;
};
data: Record<string, string | number>;
callback: (result: ProcessedResult, error?: Error) => void;
}
Now when you pass the wrong thing, the compiler tells you before the 3am alert does.
Offender #4 — not using strict mode
If your tsconfig.json doesn’t have this, everything above is running with the guardrails half-down:
{
"compilerOptions": {
"strict": true
}
}
strict: true enables a bundle of checks including strictNullChecks, noImplicitAny, and strictFunctionTypes. Without it, TypeScript is operating in “be nice” mode.
strict: true on a 100k-line project on a Friday afternoon. Enable it incrementally: start with strictNullChecks alone, fix the errors, then add noImplicitAny, and so on. Each flag surfaces a real class of bugs.Offender #5 — ignoring discriminated unions
You have a type that can be in multiple states. You probably wrote it like this:
interface ApiResponse {
data?: User;
error?: string;
loading: boolean;
}
Now every consumer has to guess which combination of fields is “valid.” Is data and error both present possible? What does loading: true with data already set mean?
Use a discriminated union instead:
type ApiResponse =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "success"; data: User };
The status field is the discriminant — TypeScript uses it to narrow the type in each branch:
function render(response: ApiResponse) {
switch (response.status) {
case "loading":
return <Spinner />;
case "error":
return <ErrorMessage text={response.message} />; // message is guaranteed here
case "success":
return <UserCard user={response.data} />; // data is guaranteed here
}
}
If you add a new status and forget to handle it, TypeScript will tell you. That’s the whole game.
The mental shift
TypeScript’s value isn’t in the syntax — it’s in encoding your assumptions into the type system so the compiler can verify them.
Every any, every loose assertion, every vague object type is a place where you’ve decided to trust yourself instead of the machine. Sometimes that’s fine. Often it’s a bug waiting to happen.
The goal isn’t zero TypeScript errors. The goal is a codebase where TypeScript errors mean something when they show up.
Quick checklist
Before merging that TypeScript PR:
- No
anythat isn’t explicitly justified with a comment - Type assertions (
as) only where runtime validation happened first -
strict: trueintsconfig.json - Interfaces use specific types, not
objectorFunction - Multi-state data modeled as discriminated unions, not optional fields
Got a TypeScript anti-pattern that still haunts you? Drop it in the comments — there’s a follow-up post in there somewhere.