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.

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.
// ❌ 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 undefinedThe 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.
// ✅ 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:
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:
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:
| Location | Typical any usage | unknown replacement |
|---|---|---|
catch blocks | catch (err: any) | catch (err: unknown), narrow with instanceof Error |
JSON.parse results | implicit any return | annotate : unknown, validate with a schema |
| External API responses | fetch(...).then(r => r.json()) typed any | type the fetch wrapper's return as unknown |
TypeScript 4.4 made the first row a non-issue by letting you type caught errors as unknown — the release notes explain the reasoning behind why any was the default before that and why it wasn't a good one:
// ❌ 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:
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
anyis a deliberate, temporary waypoint toward stricter types. - Generic constraints in rare cases where
unknownwould 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
anydisables type checking for a value and everything derived from it — errors surface at runtime instead of compile time.unknownaccepts any value but requires narrowing before use, which is what makes it safe.- Reusable type guards (
value is Type) keep narrowing concise instead of repeating checks inline. - Schema-based validation (Zod or similar) is the right tool once shapes get complex enough that hand-written guards risk drifting from reality.
catchblocks,JSON.parseresults, and external API responses are the three placesanysneaks in most often — replace all three withunknownplus a narrowing step.- Reserve
anyfor documented exceptions — untyped third-party code or a deliberate migration step — not as a default when writing a type guard feels inconvenient.


