typescript6 min read

Avoiding `any`: Practical Patterns That Still Ship

Replace `any` with `unknown`, generics, and narrowing at real-world boundaries—patterns teams use daily without generic paralysis.

any is an off switch for the type checker. It is sometimes tempting: deadline pressure, messy third-party typings, JSON from the network. Long-term, any spreads—functions return it, props accept it, and refactors become guesswork. Practical TypeScript teams minimize any without writing PhD-level generics on every Tuesday.

This post collects patterns that work in production codebases, aligned with Zod and runtime truth at the edges, gradual typing for growing codebases, and API types that survive. See the TypeScript handbook on narrowing and the unknown type for foundations.

Default to unknown at boundaries

When input is external, type it unknown, then narrow:

function handlePayload(raw: unknown) {
  if (typeof raw !== "object" || raw === null) throw new Error("Invalid");
  // narrow field by field or use Zod
}

unknown forces checks before use; any lets you call .foo() and explode later. Pair with Zod for structured data instead of manual typeof ladders when shapes grow.

Type guards and predicates

User-defined type guards document intent:

type Admin = { role: "admin"; permissions: string[] };

function isAdmin(user: User): user is Admin {
  return user.role === "admin";
}

Use in checks, discriminated unions, and Array.isArray for collections. The handbook’s discriminated unions section is the canonical reference for switch exhaustiveness with never.

satisfies instead of widening

Config objects often infer too wide:

const routes = {
  home: "/",
  app: "/dashboard",
} satisfies Record<string, `/${string}`>;

Values stay literal; typos fail compilation. Prefer over as const + manual annotation when ergonomics matter.

Generics at library boundaries, not in screens

Product components should read plainly:

function DataTable<T extends { id: string }>({ rows }: { rows: T[] }) { ... }

Reserve complex conditional types for shared utilities. If a screen needs a four-level generic, extract a typed hook.

Record and index signatures thoughtfully

Record<string, unknown> beats Record<string, any> for bags of JSON. For known keys, use mapped types or interfaces. Enable noUncheckedIndexedAccess when ready—access returns T | undefined, pushing null checks to callsites.

Catch blocks: unknown not any

try {
  await work();
} catch (e) {
  const message = e instanceof Error ? e.message : "Unknown error";
}

Log structured errors; do not assume e has .code without narrowing.

Third-party gaps

When @types are wrong or missing:

  1. Minimal local augmentation in types/vendor.d.ts
  2. Wrapper module exporting a typed facade you control
  3. Upstream PR or issue — pay forward when time allows

Avoid // @ts-ignore on import lines; fix or wrap.

JSON.parse

Always types as any historically; in strict code:

const data: unknown = JSON.parse(text);
const parsed = UserSchema.safeParse(data);

Never JSON.parse(text) as User.

React and event handlers

Prefer React.ChangeEvent<HTMLInputElement> over (e: any). For polymorphic components, generics on props:

type SelectProps<T extends string> = {
  value: T;
  onChange: (value: T) => void;
  options: readonly T[];
};

Server Components reduce client event typing surface—align with TypeScript for full-stack teams server/client splits.

Redux and global stores

Typed hooks (useAppDispatch, useAppSelector) beat untyped context. If legacy store is loose, type new slices strictly and narrow selectors per feature.

When any is temporarily acceptable

Documented escape hatches:

  • Interop with dynamic plugins where shape is unknowable—quarantine in one module, return unknown outward
  • Prototypes behind feature flags slated for rewrite—ticket + expiry date
  • Test mocks — prefer Partial<T> or factories

Never merge any to main without comment and owner.

ESLint and CI

@typescript-eslint/no-explicit-any: error in strict packages, warn in legacy during migration. Count any in CI metrics; trend down quarterly.

Code review phrases

  • “Can this be unknown + Zod?”
  • “Does this union need a discriminant?”
  • “Will this as break if API adds a field?”

Link to sibling posts when PR touches HTTP—schemas belong at edges per designing APIs.

Teaching juniors

Exercise: refactor one function from any to unknown + guard in pairing. Show IDE autocomplete appearing after narrow—immediate feedback beats lectures.

Relation to performance

Types erase; avoiding any does not slow runtime. Slightly larger generic instantiations are negligible. Do not use any “for speed.”

Assertion functions and asserts

For invariants that should never fail in internal code:

function assertNever(x: never): never {
  throw new Error("Unexpected value: " + x);
}

Use in default branches of exhaustive switches. Prefer this over casting to any to silence exhaustiveness errors.

Template literals and typed routes

Route params typed as union strings prevent typos in href:

type AppRoute = `/settings/${"profile" | "billing"}`;

Combine with Next.js typed routes experiments or hand-rolled helpers—either beats `/settings/${any}`.

Working with Map and Set

Store typed values in collections initialized from JSON by parsing entries:

const map = new Map<string, User>();
for (const [k, v] of Object.entries(raw)) {
  const user = UserSchema.parse(v);
  map.set(k, user);
}

Do not as Map<string, User> on untrusted input.

Documentation comments as types adjunct

@param and @returns in TSDoc duplicate types when maintained by hand—prefer types as source and generate docs. Where legacy JS remains, TSDoc is a migration bridge documented in gradual typing strategies.

Pairing with AI-assisted edits

Generated code loves any. Team rule: AI-suggested handlers must pass tsc and schema parse before merge. Reviewers reject drive-by any in copilot output the same as human-authored slop.

as const for literal unions

Derive unions from constant objects:

const STATUSES = { draft: "draft", live: "live" } as const;
type Status = (typeof STATUSES)[keyof typeof STATUSES];

Avoid type Status = string when only two values are legal—exhaustive UI tabs and API validators stay aligned.

Function overloads versus unions

Prefer a single argument union when possible; overloads help when return types depend on input literals in library APIs. Product code rarely needs more than one overload per function—if you have three, reconsider the API shape.

Readonly and immutability hints

ReadonlyArray<T> and readonly fields signal intent without runtime cost. They do not replace validation but prevent accidental mutation after parse—especially when passing config objects through React context.

Tuple types for fixed-length data

RGB triples, lat/long pairs, and coordinate tuples type as [number, number, number] instead of number[] when length matters. Zod offers z.tuple for the same invariant at parse time—use both when data crosses the wire.

Delegating to well-typed libraries

Prefer date-fns with proper types over new Date(anyString). Prefer fetch wrappers that return Result types. Every dependency without types is a future any hotspot—factor that into adopt-or-wrap decisions during dependency review.

Optional chaining without optional everything

obj?.field does not replace typing obj correctly—it only softens runtime crashes. Combine optional chaining with unions that reflect “missing object” versus “object without field” so callers handle both cases explicitly in business logic.

When reviewing PRs, search for newly introduced any the same way you search for console.log—a quick diff habit prevents regressions after a successful migration quarter and keeps strict packages honest.

Closing

Avoiding any in practice means unknown at edges, Zod or guards before use, discriminated unions for branching, and generics where libraries meet apps—not everywhere. Ratchet legacy code via gradual typing, keep API contracts explicit, and let the compiler guide refactors so teams ship quickly without turning off safety.

Let's talk