Home / Articles

Replacing TypeScript's `any`: Six Type-Safe Patterns for Common Cases

Learn practical, type-safe alternatives to TypeScript's `any` — including unknown, generics, discriminated unions, and exhaustive checks — for handling unpredictable data.

Publisher: REACTAPP.TOP8 min read1834 words

This article is published in English.

Sources: Stop Using ‘any’ in TypeScript — Here’s the Right Fix Every Time

A small discipline change that saves you from production surprises

Every TypeScript developer has been there. It's late on a Friday afternoon, a strange compiler error is holding up your pull request, and after twenty minutes of fighting nested generics and union types, all you want is to ship the code and shut your laptop.

So you reach for the three letters that solve everything and nothing:

const payload: any = response.data;

The red underline disappears. The build goes green. Everything looks fine.

But it isn't fine. Writing any doesn't resolve a typing question — it tells TypeScript to stop paying attention. You've switched off the very safety net that made you pick TypeScript in the first place. Each time any shows up, it creates a hole in your type coverage, and that hole quietly spreads into every function and variable that comes into contact with it.

The good news is that you almost never truly need it. Below is an explanation of why any does so much harm, followed by proven replacements for the situations where developers reach for it most.

What any Actually Means (and Why It Backfires)

A lot of developers assume any simply means "this value's type is not known yet."

What it actually communicates to the compiler is: "Stop checking this variable, and trust that everything I do with it afterward is correct."

let user: any = { name: "Alice" };

// TypeScript allows all of these without a single warning:
user.authenticate();       // Runtime crash: user.authenticate is not a function
user.address.postalCode;   // Runtime crash: Cannot read properties of undefined
user();                    // Runtime crash: user is not a function

What makes this worse is that any spreads. Once a function's return type is any, every variable that captures that return value becomes any too. Pass that value along to another function, and the loss of safety keeps propagating. Before long, large portions of your codebase are effectively plain JavaScript wearing a .ts extension.

The sections below walk through the correct alternative for each common case where any tends to creep in.

Fix 1: When the Type Genuinely Isn't Known Yet (unknown)

The situation: You're pulling data from an external API, parsing a JSON string pulled out of local storage, or handling a raw payload from a webhook. In these cases, there's no way to know the exact shape of the data before the program actually runs.

The problematic approach:

function parseApiResponse(jsonString: string): any {
  return JSON.parse(jsonString);
}

const data = parseApiResponse('{"id": 101}');
console.log(data.profile.avatar); // Compiles fine, crashes immediately if profile is missing

The correct fix: Reach for unknown combined with a type guard.

unknown works like a safer sibling of any. It tells the compiler: "This value's type isn't determined yet, but you're not allowed to access properties, call methods, or otherwise operate on it until you've proven what it actually is."

interface UserProfile {
  id: number;
  name: string;
}

// Custom type guard
function isUserProfile(value: unknown): value is UserProfile {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    typeof (value as Record<string, unknown>).id === "number" &&
    "name" in value &&
    typeof (value as Record<string, unknown>).name === "string"
  );
}

function parseApiResponse(jsonString: string): unknown {
  return JSON.parse(jsonString);
}

const rawData = parseApiResponse('{"id": 101, "name": "Sarah"}');

// rawData.name; // Error: Object is of type 'unknown'.

if (isUserProfile(rawData)) {
  // Inside this block, TypeScript knows rawData is definitely UserProfile
  console.log(rawData.name); // Fully typed, autocompleted, and safe!
}

If hand-writing guards for complicated payloads feels like too much overhead, you can use a runtime validation library such as Zod or Valibot. These libraries validate unknown input and derive the matching TypeScript type for you in a single step.

Fix 2: When One Function Needs to Support Several Types (Generics)

The situation: You're building a reusable utility — think of a caching wrapper, an array helper, or a method on an API client — meant to operate across many different data shapes.

The problematic approach:

function getFirstItem(items: any[]): any {
  return items[0];
}

const numbers = [1, 2, 3];
const firstNum = getFirstItem(numbers); // firstNum is typed as 'any'
firstNum.toUpperCase(); // Compiles! Crashes at runtime because it's a number.

The correct fix: Reach for generics (the <T> syntax).

Generics act like placeholders for types rather than values. Instead of discarding type information the way any does, a generic remembers the type supplied by the caller and carries it all the way through to what the function returns.

function getFirstItem<T>(items: T[]): T | undefined {
  return items[0];
}

const numbers = [1, 2, 3];
const firstNum = getFirstItem(numbers);
// TypeScript automatically infers firstNum as: number | undefined

const names = ["Alice", "Bob"];
const firstName = getFirstItem(names);
// TypeScript automatically infers firstName as: string | undefined

You can also restrict what a generic is allowed to be by using extends, ensuring the type includes certain required properties:

interface HasId {
  id: string | number;
}

function printId<T extends HasId>(entity: T): void {
  console.log(entity.id);
}

Fix 3: Handling Errors Inside try / catch

The situation: You're writing logic inside a catch block to deal with something that went wrong.

The problematic approach:

try {
  fetchUserData();
} catch (err: any) {
  // Risky: In JavaScript, anything can be thrown (strings, numbers, null)
  console.error(err.message.toUpperCase());
}

The risk here is that JavaScript lets you throw literally anything — strings, numbers, null, plain objects, not just Error instances. If some library or an unexpected failure throws a plain string, such as throw "Network timeout", then err.message will be undefined, and calling something like .toUpperCase() on it can throw a fresh, unhandled error right inside the code meant to handle errors.

The correct fix: Type the caught error as unknown and narrow it using instanceof Error.

try {
  fetchUserData();
} catch (err: unknown) {
  if (err instanceof Error) {
    // TypeScript safely knows 'err' has a .message property
    console.error(`Error: ${err.message}`);
  } else {
    // Fallback for non-standard thrown objects
    console.error("An unexpected error occurred", String(err));
  }
}

Fix 4: Dynamic Objects and Key-Based Lookups

The situation: You need an object that behaves like a map or dictionary, with keys that are only known while the program is running.

The problematic approach:

const userRolePermissions: any = {};
userRolePermissions["admin"] = ["read", "write", "delete"];

The correct fix: Use Record<Keys, Values>, or alternatively an indexed signature.

type Role = "admin" | "editor" | "viewer";
type Permission = "read" | "write" | "delete";

// A strictly defined key-value mapping
const rolePermissions: Record<Role, Permission[]> = {
  admin: ["read", "write", "delete"],
  editor: ["read", "write"],
  viewer: ["read"],
};

// If the keys can be arbitrary dynamic strings:
const sessionCache: Record<string, unknown> = {};

Using Record<string, unknown> keeps the shape of the object checkable while still forcing anyone consuming it to verify what a value actually is before touching it.

Fix 5: State That Changes Shape (Discriminated Unions)

The situation: A process moves through distinct phases — think idle, loading, success, and error — and each phase carries its own kind of data.

The problematic approach:

interface RequestState {
  status: any;
  data?: any;
  error?: any;
}

Modeling this with a single loose interface means every property becomes optional, forcing you to chain ?. everywhere in your code. Worse, nothing stops you from ending up in a nonsensical state where both data and error are populated at once.

The correct fix: Reach for discriminated unions.

Give every possible state a shared literal field — commonly named status or type — that TypeScript can use to tell the variants apart:

type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: string[] }
  | { status: "error"; error: Error };

function renderUI(state: RequestState) {
  switch (state.status) {
    case "idle":
      return "Click start";
    case "loading":
      return "Loading...";
    case "success":
      // TypeScript automatically knows state.data exists here!
      return `Loaded ${state.data.length} items`;
    case "error":
      // TypeScript automatically knows state.error exists here!
      return `Failed: ${state.error.message}`;
  }
}

Fix 6: Exhaustive Checking with never

The situation: You want the compiler itself to flag it when a teammate adds a new case to a union type but forgets to update every switch or if/else chain that handles it.

The problematic approach: Falling back to a catch-all default: return any, or simply not handling the extra case at all.

The correct fix: Assign whatever's left over to the never type.

never describes a value that logically cannot occur. Once your switch statement has covered every member of the union, whatever remains in the default branch is narrowed down to never. If someone later extends the union with a new variant, the compiler immediately flags an error at that line, because the leftover value no longer fits never:

type NotificationType = "email" | "sms" | "push";

function sendNotification(type: NotificationType) {
  switch (type) {
    case "email":
      // Handle email
      break;
    case "sms":
      // Handle SMS
      break;
    case "push":
      // Handle push notification
      break;
    default: {
      // If someone adds "in_app" to NotificationType and forgets to update this function,
      // TypeScript will raise a compile-time error right here.
      const _exhaustiveCheck: never = type;
      return _exhaustiveCheck;
    }
  }
}

Quick Reference: Replacing any

How to Lock It Down Across Your Team

Understanding these replacements only solves half the problem — the other half is stopping any from sneaking back in the moment a deadline gets tight.

Bake the restriction directly into your project configuration so it's enforced automatically:

In tsconfig.json:

{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true
  }
}

In your ESLint configuration (.eslintrc.json):

{
  "rules": {
    "@typescript-eslint/no-explicit-any": "error"
  }
}

The whole point of TypeScript is to give you confidence — confidence while refactoring, confidence while deploying, and editor autocomplete that spares you constant trips to the documentation. Cutting out any can feel constraining for the first couple of days. But once unknown, generics, and discriminated unions take its place, your code starts explaining itself, runtime failures drop sharply, and the compiler finally works as the safeguard it was designed to be.