unknown vs any.
The line between safe and asleep.
Both any and unknown can hold any value. The difference is at the check site. any says "trust me, don't check." unknown says "I don't know yet — prove it before you use it." One disables your safety net; the other forces you to use it. This is the most consequential choice in the whole type system, and it's the one most beginners get wrong.
Short answer: Use unknown for any value that enters from outside your type system — JSON.parse, fetch().json(), user input, third-party callbacks. Then narrow it with typeof, in, or a custom guard before use. Use any only for interop with untyped libraries, gradual migrations, or a well-documented escape hatch. Everywhere else, unknown is the correct choice.
✅ Solution 1 — unknown for external data
The API boundary pattern — accept unknown, narrow, then use.
When data comes from outside TypeScript's world — a server, a file, user input — you don't know its shape at compile time. Typing it as any pretends you do and disables the compiler. Typing it as unknown forces you to narrow before use. This is the correct pattern for every API response.
// ─── The problem: any disables everything ──── function badFetch(data: any): string { // No error even though data has nothing: // data.name.toUpperCase() ← crashes at runtime // data.foo.bar.baz() ← crashes at runtime // data + ' concatenation' ← produces "undefined" return data.name.toUpperCase(); // ✅ compiles ❌ crashes } // ─── The fix: unknown forces narrowing ─────── interface User { name: string; email: string } function isUser(x: unknown): x is User { return ( typeof x === 'object' && x !== null && 'name' in x && 'email' in x && typeof (x as User).name === 'string' && typeof (x as User).email === 'string' ); } function goodFetch(data: unknown): string { // return data.name; // ❌ 'data' is of type 'unknown' if (!isUser(data)) { throw new Error('Invalid response'); } return data.name.toUpperCase(); // ✅ narrowed + safe } // ─── Narrowing unknown with typeof ────────── function describe(x: unknown): string { if (typeof x === 'string') return string: ${x}number: ${x}; if (Array.isArray(x)) return array of ${x.length}`; if (x === null) return 'null'; if (typeof x === 'object') return 'object'; return 'something else'; } console.log(describe('hi'), describe(42), describe([1, 2])); try { console.log(goodFetch({ name: 'Alice', email: 'a@x.com' })); } catch (e) { console.log(e); }
function badFetch(data) { return data.name.toUpperCase(); } function isUser(x) { return ( typeof x === 'object' && x !== null && 'name' in x && 'email' in x && typeof x.name === 'string' && typeof x.email === 'string' ); } function goodFetch(data) { if (!isUser(data)) { throw new Error('Invalid response'); } return data.name.toUpperCase(); } function describe(x) { if (typeof x === 'string') return string:${x}number: {x.length}`; if (x === null) return 'null'; if (typeof x === 'object') return 'object'; return 'something else'; } console.log(describe('hi'), describe(42), describe([1, 2])); try { console.log(goodFetch({ name: 'Alice', email: 'a@x.com' })); } catch (e) { console.log(e); }
any disables all checks
data.name.toUpperCase() compiles when data: any, even though data may be 42 at runtime. You get zero safety.
unknown forces narrowing
With data: unknown, accessing data.name fails to compile. You must narrow with typeof, in, or a guard first.
The API boundary pattern
Accept unknown, narrow with a guard, use the narrowed type. Never cast (as User) — that's just any in disguise.
Narrowing tools work
typeof, Array.isArray, instanceof, in, and custom guards all work on unknown. Same tools as unions.
Treat unknown as the default for anything you didn't construct. Function params from external libraries? Unknown. JSON.parse results? Unknown. Third-party callback payloads? Unknown. Event data from non-React sources? Unknown. Once you adopt this habit, you'll spot bugs the compiler was previously hiding — real bugs that would have crashed at runtime. The any in old code is a debt; unknown is paying it down.
✅ Solution 2 — When any is actually acceptable
Three legitimate cases. Everything else is a smell.
Blanket advice "never use any" is wrong. There are three situations where any is the right tool. All three are narrow, documented, and temporary. Knowing them prevents both overuse (which disables the compiler) and rigid avoidance (which makes interop painful).
// ─── Case 1: Untyped library interop ───────── // An old library ships .js with no .d.ts and no @types. // You can't fix the library. Use any to unblock. // some-legacy-lib.d.ts — a hand-written shim: declare module 'some-legacy-lib' { export function doSomething(input: any): any; // ⚠️ Marked with a comment why — this is a known escape hatch } // Better than declare module 'some-legacy-lib'; (whole module any) // because at least the function name is typed. // ─── Case 2: Gradual migration ─────────────── // Migrating a legacy .js file to .ts. // Start with // @ts-nocheck at the top, then type // one function at a time, removing any as you go. // legacy.ts — Day 1: // // @ts-nocheck // export function processData(data: any) { ... } // // Day 30: // export function processData(data: UserInput) { ... } // // By the end, no any remains. The path is fine; // the destination is the goal. // ─── Case 3: Documented internal escape hatch ─ // Sometimes the boundary truly is unknowable. // The rule: (1) it's internal, (2) it's documented, // (3) it's localized to one function. function parseJSONOrThrow(text: string): unknown { try { return JSON.parse(text); // JSON.parse returns any — cast to unknown } catch { throw new Error('Invalid JSON'); } } // ─── What "acceptable any" looks like ──────── // // ✅ Marked with a comment explaining why // ✅ Localized to one function or one module // ✅ Paired with a guard at the entry point // ✅ Has a plan to remove it // // ❌ Spread across a file // ❌ Used in a public API signature // ❌ Used to "make the error go away" // ❌ No comment, no plan // ─── The migration path ────────────────────── // Any → unknown (start here) // unknown → type guard (validate) // type guard → typed input (ship) console.log(parseJSONOrThrow('{"a":1}'));
// All any and unknown types are erased. function parseJSONOrThrow(text) { try { return JSON.parse(text); } catch { throw new Error('Invalid JSON'); } } console.log(parseJSONOrThrow('{"a":1}'));
Legacy library interop
No .d.ts, no @types. Use any in a hand-written shim. Better: write real types. But any unblocks migration.
Gradual migration
Migrating .js → .ts with // @ts-nocheck and per-function typing. any is a temporary waypoint, not a destination.
Documented escape hatch
Internal, localized, commented. Rare. Never in public APIs. Never "to make the error go away."
The migration path
any → unknown → type guard → typed input. Anywhere you'd write any, write unknown first. The compiler will tell you what needs narrowing.
any spreads like a virus. One any in a function's return type infects every call site — those call sites become any too, and their call sites, and so on. A single any in an API wrapper can disable type checking across half your codebase. This is why unknown is strictly better: it's contagious in the other direction — unknown forces narrowing, which forces real types, which keeps the rest of your code checked. When in doubt, unknown. Always.
✅ Solution 3 — Converting any to unknown (a migration recipe)
The mechanical process for cleaning up existing any.
You inherited a codebase with any everywhere. You want to fix it. This is the step-by-step process — the same one used by large teams migrating to strict TypeScript. It's mechanical, safe, and doesn't require rewriting anything.
// ─── STEP 1: Change any → unknown ──────────── // Every compile error now points to a place // where the type was relied on. Fix each one. // BEFORE: // function processData(data: any) { // return data.user.name; // } // AFTER STEP 1: // function processData(data: unknown) { // return data.user.name; // // ❌ 'data' is of type 'unknown' // // → this is the compiler telling you where to narrow // } // ─── STEP 2: Add a type guard ───────────────── interface Payload { user: { name: string; email: string }; } function isPayload(x: unknown): x is Payload { if (typeof x !== 'object' || x === null) return false; const u = (x as Record<string, unknown>).user; if (typeof u !== 'object' || u === null) return false; const user = u as Record<string, unknown>; return typeof user.name === 'string' && typeof user.email === 'string'; } // ─── STEP 3: Use the guard in the function ──── function processData(data: unknown): string { if (!isPayload(data)) { throw new Error('Invalid payload'); } return data.user.name; // ✅ narrowed to Payload } // ─── STEP 4: Tighten the signature at the source // If the caller controls the type, change it: // function sendData(data: Payload) { ... } // Now the type checker enforces correctness // from creation to use — no guard needed at the boundary. // ─── The migration checklist ────────────────── // [ ] Replace every : any with : unknown // [ ] Fix the resulting compile errors one by one // [ ] Add a type guard for each unknown input // [ ] Change signatures to concrete types where possible // [ ] Add a lint rule: no-explicit-any (warn first, error later) // [ ] Repeat per module or per feature console.log(processData({ user: { name: 'Alice', email: 'a@x.com' } }));
function isPayload(x) { if (typeof x !== 'object' || x === null) return false; const u = x.user; if (typeof u !== 'object' || u === null) return false; const user = u; return typeof user.name === 'string' && typeof user.email === 'string'; } function processData(data) { if (!isPayload(data)) { throw new Error('Invalid payload'); } return data.user.name; } console.log(processData({ user: { name: 'Alice', email: 'a@x.com' } }));
any → unknown first
Don't try to write the right type immediately. Change any to unknown — the compiler will list every place you need to narrow.
Errors are a map
Each compile error is a place where you previously relied on an unchecked value. Fix them one by one — the errors are the work plan.
Guards are reusable
One isPayload guard covers every place that narrows the same shape. Write it once, use it everywhere.
Prevent regression
Add @typescript-eslint/no-explicit-any as a warning. New any gets a comment justifying it. The rule keeps the codebase clean.
⚠️ Common mistakes
The unknown/any bugs that catch everyone.
any to make errors go awayThe fastest way to "fix" a compile error is as any. It also hides every other type error in that line. Fix: fix the real type, or use unknown + guard.
unknown to a specific typedata as User — this is any in disguise. It tells the compiler "trust me" without checking. Fix: narrow with a real check (typeof, guard).
any from a functionfunction f(): any — every caller is now any. Fix: return unknown or a concrete type. Let callers narrow.
anyfetch(...).then(r => r.json() as any) — trusts the server's shape. Fix: as unknown, then a guard. Validate at the boundary.
unknown but accessing without narrowing(data: unknown) => data.name — compile error. Fix: narrow first (typeof, in, guard), then use.
function isUser(x): x is User { return true; } compiles but narrows incorrectly. Fix: the guard body must actually validate.
any in public APIsexport function f(x: any) — every consumer loses type safety. Fix: unknown + guard, or a real type. Public APIs set the tone.
unknown is unusable"I can't do anything with unknown" — you can. You narrow it, then use the narrowed type. Same as any other union. Fix: learn the narrowing patterns from Lesson 4.
❓ Frequently asked
What is the difference between unknown and any in TypeScript?
Both can hold any value, but they behave differently at the check site. any disables type checking entirely — you can call any method, access any property, and the compiler won't complain. unknown forces you to narrow before use — you can't call methods or access properties until you've proven the type.
When should I use unknown?
Use unknown for any value that arrives from outside your type system: JSON.parse results, fetch response bodies, user input, third-party callbacks, dynamic imports. The pattern: accept unknown, narrow with typeof, in, Array.isArray, or a custom type guard, then use the narrowed type.
Is any ever acceptable in TypeScript?
Yes — in three specific cases. (1) Interop with an untyped library that ships no .d.ts and no @types package. (2) Gradual migration. (3) Escape hatch inside a well-tested function where the boundary is genuinely unknowable — annotated with a comment explaining why. Everywhere else, use unknown.
How do I narrow unknown?
Same techniques as narrowing unions: typeof x === 'string', Array.isArray(x), x instanceof Date, 'field' in x, or a custom type guard. The difference: with unknown, you must narrow before every use — you can't just access .foo or call .bar().