Skip to content

Stop Reaching for any: Narrowing unknown in TypeScript

Why unknown is the type-safe replacement for any, and the narrowing patterns that make it practical in real code.

4 min read
Code editor showing TypeScript unknown type narrowing with type guards

Every codebase I've inherited has the same tell: a grep for any returns hundreds of hits, and half of them sit at exactly the places where a bug later showed up in production. any isn't a type — it's an instruction to the compiler to stop checking. unknown gives you the same flexibility at the boundary of your program without giving up the safety net everywhere downstream. The reason teams don't use it more is that nobody taught them how to narrow it without writing a wall of if statements. That part is the actual skill.

What any actually costs you

any is contagious. Once a value is typed any, every property access, every function call, every arithmetic operation on it is also any — and TypeScript happily propagates that through your entire call chain without a single error.

tstypescript
// ❌ any silently disables checking for everything downstream
function parseConfig(raw: any) {
  return {
    port: raw.port,
    // typo goes unnoticed — "confg.hsot" compiles fine
    host: raw.hsot,
    timeout: raw.timeout * 1000,
  };
}
 
const config = parseConfig(JSON.parse(fileContents));
config.host.toUpperCase(); // runtime crash: host is undefined

The compiler gave zero warnings here. raw.hsot is a typo, raw.timeout * 1000 assumes a number that might be a string, and config.host is any all the way to the call site where it finally blows up — at runtime, in front of a user, three files away from where the mistake was actually made.

unknown as the type-safe boundary

unknown is the type-safe counterpart described in the TypeScript Handbook's section on narrowing: you can assign anything to it, but you can't do anything with it until you've proven what it is.

tstypescript
// ✅ unknown forces you to prove the shape before using it
function parseConfig(raw: unknown): { port: number; host: string } {
  if (
    typeof raw !== "object" ||
    raw === null ||
    !("port" in raw) ||
    !("host" in raw)
  ) {
    throw new Error("Invalid config shape");
  }
 
  const { port, host } = raw as { port: unknown; host: unknown };
 
  if (typeof port !== "number" || typeof host !== "string") {
    throw new Error("Invalid config field types");
  }
 
  return { port, host };
}

More verbose, yes. But the typo from the previous example is now a compile error, not a 2 a.m. page. That trade — a few extra lines at the boundary in exchange for correctness everywhere downstream — is almost always worth it.

Narrowing techniques that don't feel like busywork

The friction with unknown disappears once you have a small toolkit of narrowing patterns you reuse instead of reinventing per call site.

Type guards are the cleanest option when the check is reusable:

tstypescript
interface User {
  id: string;
  email: string;
}
 
function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    typeof (value as Record<string, unknown>).id === "string" &&
    typeof (value as Record<string, unknown>).email === "string"
  );
}
 
function greet(value: unknown) {
  if (!isUser(value)) {
    throw new Error("Expected a User");
  }
  // value is narrowed to User from this line onward
  console.log(`Hello, ${value.email}`);
}

Runtime validation libraries remove the manual guard entirely once shapes get complex enough that hand-written checks become error-prone themselves. Zod is the pattern I reach for most often:

tstypescript
import { z } from "zod";
 
const UserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
});
 
type User = z.infer<typeof UserSchema>;
 
function parseUser(payload: unknown): User {
  // throws with a descriptive error if payload doesn't match
  return UserSchema.parse(payload);
}

This is the same idea as the manual type guard, but the schema is also your single source of truth for both the runtime check and the compile-time type — no risk of the two drifting apart.

Where unknown actually shows up in real code

Three spots account for most of the any you'll find in an existing codebase, and all three have a direct unknown-based replacement:

LocationTypical any usageunknown replacement
catch blockscatch (err: any)catch (err: unknown), narrow with instanceof Error
JSON.parse resultsimplicit any returnannotate : unknown, validate with a schema
External API responsesfetch(...).then(r => r.json()) typed anytype the fetch wrapper's return as unknown

TypeScript 4.4 made the first row a non-issue by letting you type caught errors as unknownthe release notes explain the reasoning behind why any was the default before that and why it wasn't a good one:

tstypescript
// ❌ err is any — every property access is unchecked
try {
  await saveUser(user);
} catch (err: any) {
  console.error(err.message); // works, but also compiles if err is a string
}
 
// ✅ err is unknown — you have to prove it's an Error first
try {
  await saveUser(user);
} catch (err: unknown) {
  const message = err instanceof Error ? err.message : String(err);
  console.error(message);
}

JSON.parse is the same story in miniature: its return type is any by default in the standard library's typings, which means the moment you call it, you've silently re-introduced every problem unknown was supposed to solve. Wrap it once:

tstypescript
function parseJson(text: string): unknown {
  return JSON.parse(text);
}

and every call site is forced to validate before it can use the result.

When any is still the right call

unknown isn't a rule to apply everywhere without exception. any still has a few legitimate uses:

  • Third-party libraries with missing or broken type definitions, as a documented, isolated escape hatch — not spread across your own code.
  • Gradual migration of a large JavaScript codebase, where any is a deliberate, temporary waypoint toward stricter types.
  • Generic constraints in rare cases where unknown would force unnecessary casting in code that's provably safe by construction (a well-tested internal utility, for instance).

The difference between these and the parseConfig example earlier is intent. any as a conscious, narrow decision is fine. any as the default because nobody wanted to write a type guard is where bugs come from.

Key Takeaways

  1. any disables type checking for a value and everything derived from it — errors surface at runtime instead of compile time.
  2. unknown accepts any value but requires narrowing before use, which is what makes it safe.
  3. Reusable type guards (value is Type) keep narrowing concise instead of repeating checks inline.
  4. Schema-based validation (Zod or similar) is the right tool once shapes get complex enough that hand-written guards risk drifting from reality.
  5. catch blocks, JSON.parse results, and external API responses are the three places any sneaks in most often — replace all three with unknown plus a narrowing step.
  6. Reserve any for documented exceptions — untyped third-party code or a deliberate migration step — not as a default when writing a type guard feels inconvenient.
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX