Avoiding 'Any' in TypeScript: Why it Exists and Remedies

Felipe Hlibco

Last day of 2023, and I’m thinking about types. Specifically, the type that undoes all your other types.

I’ve been writing TypeScript since before it was cool (and through the years where it still wasn’t). Every codebase I’ve inherited or built has had any scattered through it like landmines. Some justified. Most not. At DreamFlare, we’ve been slowly de-mining our codebase this quarter, and I figured I’d share the patterns that keep coming up.

Why ‘any’ exists in the first place #

It’s easy to demonize any, but it exists for real reasons. TypeScript was designed to be a gradual typing system on top of JavaScript. That “gradual” part means there has to be an escape hatch; otherwise nobody would migrate.

Three legitimate use cases keep any alive:

JavaScript migration. You’re converting a 50k-line JS codebase to TypeScript. You can’t type everything at once. any lets you migrate file by file without blocking the build. This is the original intended use, and it’s valid — as long as it’s temporary.

Untyped third-party code. Some npm packages still ship without types, and DefinitelyTyped doesn’t always have them. When the alternative is writing a complete type declaration for a library you’re using two functions from, any is a pragmatic choice.

Genuinely dynamic data. Sometimes data really is dynamic — user-provided JSON blobs, plugin systems with arbitrary shapes, metaprogramming scenarios. These cases exist, though they’re rarer than people think.

The problem is that any spreads. One any parameter in a function poisons every downstream consumer. TypeScript won’t warn you when you pass that untyped value into something that expects a string. The type system silently gives up.

The remedies I actually use #

unknown: the safe escape hatch #

unknown is the type-safe version of any. Both accept any value; the difference is that unknown forces you to narrow before you use it.

function processInput(data: unknown) {
  // data.name  <-- Error! Can't access properties on unknown
  if (typeof data === 'object' && data !== null && 'name' in data) {
    console.log(data.name); // OK after narrowing
  }
}

This is my default replacement for any in most scenarios. The narrowing step feels annoying the first few times, but it catches real bugs. I’ve lost count of the runtime crashes we’ve avoided by forcing type guards at boundaries.

Generics for reusable code #

When a function needs to work with multiple types, reach for generics instead of any:

// Bad: loses type information
function first(arr: any[]): any { return arr[0]; }

// Good: preserves the relationship
function first<T>(arr: T[]): T { return arr[0]; }

The generic version keeps the contract. The caller gets back the same type they put in. With any, that relationship evaporates.

Union types for known sets #

If you know the possible shapes but there are several of them, union types and discriminated unions handle it cleanly:

type ApiResponse =
  | { status: 'success'; data: User }
  | { status: 'error'; message: string }
  | { status: 'loading' };

This pattern surfaces in API responses, event systems, state machines — anywhere the shape varies but the possibilities are finite. Discriminated unions give you exhaustive checking in switch statements, which is one of TypeScript’s best features.

Record<string, unknown> for flexible objects #

When you need a bag of key-value pairs but don’t know the values:

// Instead of: Record<string, any>
const config: Record<string, unknown> = loadConfig();

// Forces you to check types before using values
const port = typeof config.port === 'number' ? config.port : 3000;

Small change, big impact. The unknown values force validation at the usage site.

The satisfies operator (TS 4.9+) #

This one’s relatively recent and underused. satisfies lets you validate that a value matches a type without widening it:

const routes = {
  home: '/home',
  about: '/about',
  contact: '/contact',
} satisfies Record<string, string>;

// routes.home is still the literal type '/home', not just string

Without satisfies, you’d need a type annotation that widens the literals. Or you’d skip the annotation and lose the shape validation. satisfies gives you both.

Project-level guardrails #

Individual discipline only goes so far. Two tsconfig flags make any avoidance a team-level practice:

noImplicitAny: true — Forces explicit type annotations where TypeScript can’t infer. This catches the accidental any values that slip in when you forget a return type or parameter annotation.

strict: true — Enables all strict checks including noImplicitAny. I won’t start a new project without it. For existing projects, migrating to strict mode is harder but worth doing incrementally.

At DreamFlare we also run typescript-eslint with the no-explicit-any rule set to warn. We don’t error on it because sometimes any is the pragmatic choice — but the warning makes it visible in PRs.

The real lesson #

The goal isn’t zero any. That’s an unrealistic purity standard that leads to overengineered type gymnastics. The goal is intentional any — every remaining instance should be a deliberate choice with a comment explaining why, not a lazy shortcut.

End of year, end of rant. Go check your codebase’s any count. I bet it’s higher than you think.