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.


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.


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

When is `any` actually okay?
Third-party libraries without type definitions, emergency escape hatches when migrating a legacy codebase, or when you’re explicitly working with dynamically-shaped data that you’ll narrow immediately. The key word is intentional. Use // eslint-disable-next-line @typescript-eslint/no-explicit-any with a comment explaining why — future you will thank present you.

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


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.


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.

Enabling strict on an existing codebase
Don’t flip 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.

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.


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.


Before merging that TypeScript PR:

  • No any that isn’t explicitly justified with a comment
  • Type assertions (as) only where runtime validation happened first
  • strict: true in tsconfig.json
  • Interfaces use specific types, not object or Function
  • 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.