unknown, any, and never in TypeScript: which one to use
1. Overview
TypeScript has three special types that are easy to confuse: any, unknown, and never. All three appear at the “edges” of the type system. any turns off type checking, unknown forces you to check before using it, and never represents a case that cannot happen.
After reading this article, you’ll know which type to choose for data of uncertain origin. You’ll also know how to use never to have the compiler report an error when you forget to handle a branch.
All examples run with TypeScript 5.x and "strict": true in tsconfig.json.
2. any: turn off type checker
any allows a value to be anything. The compiler doesn’t check anything:
let payload: any = "hello";
payload.toUpperCase(); // OK
payload.foo.bar(); // OK at compile, error at runtime
payload = 42;
const total: number = payload * 2; // OK
The third line compiles fine. When run, it throws an error:
TypeError: Cannot read properties of undefined (reading 'bar')
With any, TypeScript considers you responsible. You lose autocomplete, lose property name checking, and lose the benefit of safe refactoring.
2.1. any spreads
The biggest problem with any is that it spreads to everything it touches:
function loadConfig(): any {
return JSON.parse('{"port": "8080"}');
}
const config = loadConfig();
const port = config.port; // port: any
const next = port + 1; // next: any
console.log(next);
80801
port is the string "8080", so port + 1 produces "80801" instead of 8081. The compiler doesn’t warn because any has propagated through config, port, and then next. A single any at the start can lose type checking across an entire chain of function calls.
Note:
JSON.parse,response.json()in the Fetch API, and many older libraries returnany. This is the most common source ofanyleaks in real-world code.
3. unknown: safe when the type is not yet known
unknown also accepts any value like any. The difference is you cannot use that value until you’ve narrowed the type:
let payload: unknown = "hello";
payload.toUpperCase();
error TS18046: 'payload' is of type 'unknown'.
The compiler blocks it immediately. You must prove payload is a string first:
if (typeof payload === "string") {
console.log(payload.toUpperCase()); // payload: string
}
HELLO
Inside the if block, TypeScript knows payload is a string, so toUpperCase() is valid.
3.1. Ways to narrow unknown
There are four main tools:
class PaymentError extends Error {
constructor(message: string, public readonly code: string) {
super(message);
}
}
function describe(value: unknown): string {
if (typeof value === "number") {
return `Number: ${value.toFixed(2)}`;
}
if (value instanceof PaymentError) {
return `Payment error ${value.code}`;
}
if (typeof value === "object" && value !== null && "id" in value) {
return `Object with id: ${String(value.id)}`;
}
return "Unknown";
}
console.log(describe(12.5));
console.log(describe(new PaymentError("Insufficient funds", "INSUFFICIENT")));
console.log(describe({ id: 7 }));
console.log(describe(null));
Number: 12.50
Payment error INSUFFICIENT
Object with id: 7
Unknown
typeoffor primitive types:string,number,boolean…instanceoffor classes.inchecks if a property exists. Since TypeScript 4.9, after"id" in value,valuehas typeobject & Record<"id", unknown>.- You must check
value !== nullbecausetypeof null === "object".
3.2. Type guard for external data
For complex objects, you should write a type guard, which is a function that returns value is T:
interface Order {
id: string;
customerName: string;
total: number;
}
export function isOrder(value: unknown): value is Order {
if (typeof value !== "object" || value === null) return false;
const v = value as Record<string, unknown>;
return (
typeof v.id === "string" &&
typeof v.customerName === "string" &&
typeof v.total === "number"
);
}
export function parseOrder(json: string): Order {
const data: unknown = JSON.parse(json);
if (!isOrder(data)) {
throw new Error("Order data is invalid");
}
return data; // data: Order
}
console.log(parseOrder('{"id":"A1","customerName":"Lan","total":250000}'));
{ id: 'A1', customerName: 'Lan', total: 250000 }
The key point is the line const data: unknown = JSON.parse(json). Explicitly assigning the unknown type stops any from JSON.parse from leaking. From here on, the compiler forces you to check before using data.
In large projects, you can replace hand-written type guards with a schema validation library like Zod. The principle remains the same: data in is unknown, and only after validation does it become a specific type.
3.3. unknown in catch
With strict: true (specifically the useUnknownInCatchVariables flag), variables in catch have type unknown:
try {
parseOrder("{}");
} catch (err) {
// err: unknown
const message = err instanceof Error ? err.message : String(err);
console.log(message);
}
Order data is invalid
This is true to reality. JavaScript allows you to throw anything, even strings or numbers, so you cannot assume err is always an Error.
4. never: the type with no value
never is the empty type. No value belongs to type never. It appears in three main situations.
4.1. Functions that never return
A function that always throws an error, or runs an infinite loop, has a return type of never:
function fail(message: string): never {
throw new Error(message);
}
function getDiscount(code: string): number {
if (code === "TET2026") return 0.2;
return fail(`Discount code not found: ${code}`);
}
Because never is assignable to every type, return fail(...) is valid in a function returning number. The compiler also understands that code after the fail() call is unreachable.
4.2. Exhaustive checking
This is the most useful way to use never. Suppose you have a union of payment methods:
type PaymentMethod =
| { kind: "card"; last4: string }
| { kind: "cash" }
| { kind: "momo"; phone: string };
export function assertNever(value: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}
export function paymentLabel(method: PaymentMethod): string {
switch (method.kind) {
case "card":
return `Card **** ${method.last4}`;
case "cash":
return "Cash";
case "momo":
return `MoMo ${method.phone}`;
default:
return assertNever(method);
}
}
After handling all three cases, in the default branch, method is narrowed to never. The call to assertNever(method) is therefore valid.
Now suppose a colleague adds { kind: "zalopay"; phone: string } to the union but forgets to update the switch. The compiler reports it immediately:
error TS2345: Argument of type '{ kind: "zalopay"; phone: string; }' is not assignable to parameter of type 'never'.
The error is caught at compile time, instead of leaving users with an undefined label on the payment screen. If unexpected data still slips through at runtime, for example from a legacy API, assertNever will throw a clear error.
4.3. never in conditional types
In conditional types, never is used to remove a member from a union, because A | never is just A:
type OnlyStrings<T> = T extends string ? T : never;
type Mixed = "vnd" | "usd" | 404 | null;
type Currencies = OnlyStrings<Mixed>; // "vnd" | "usd"
TypeScript distributes the conditional type over each member of the union. Members 404 and null become never and then disappear. Built-in utility types like Exclude, Extract, and NonNullable all work this way.
5. Quick comparison
The core difference lies in assignability:
| Assign other values to this type | Assign this type to other types | Use directly (call methods, access properties) | |
|---|---|---|---|
any |
Yes | Yes (except never) |
Yes, no checking |
unknown |
Yes | Only to unknown, any |
No, must narrow |
never |
No (except never) |
Yes, to any type | No value to use |
It can be summarized as follows:
unknownis the safe top type. Everything isunknown.neveris the bottom type.neveris a subtype of every type.anyis the escape hatch. It acts as both a top type and a bottom type, which breaks the soundness of the type system.
6. Testing behavior
You can test both runtime behavior and compile-time types with Vitest. Installation:
npm install -D vitest typescript
Suppose the functions from sections 3.2 and 4.2 are in order.ts and payment.ts:
// payment.test.ts
import { describe, it, expect, expectTypeOf } from "vitest";
import { isOrder, parseOrder } from "./order";
import { paymentLabel, assertNever } from "./payment";
describe("unknown and type guard", () => {
it("accepts valid orders", () => {
const order = parseOrder('{"id":"A1","customerName":"Lan","total":1000}');
expect(order.total).toBe(1000);
expectTypeOf(order.total).toEqualTypeOf<number>();
});
it("rejects data of wrong type", () => {
expect(isOrder({ id: "A1", customerName: "Lan", total: "1000" })).toBe(false);
expect(() => parseOrder("null")).toThrow("Order data is invalid");
});
});
describe("never and exhaustive check", () => {
it("returns the correct label for each method", () => {
expect(paymentLabel({ kind: "card", last4: "4242" })).toBe("Card **** 4242");
expect(paymentLabel({ kind: "cash" })).toBe("Cash");
});
it("throws error when encountering data outside the union at runtime", () => {
const legacy = { kind: "paypal" } as unknown as never;
expect(() => assertNever(legacy)).toThrow("Unhandled case");
});
});
npx vitest run
✓ payment.test.ts (4 tests)
Test Files 1 passed (1)
Tests 4 passed (4)
expectTypeOf checks types at compile time. If any change makes order.total become any, the test will fail when running type check (vitest --typecheck). The last test simulates “unexpected” data from a legacy system to ensure the safety net at runtime still works.
7. Common mistakes and when not to use
Using any to “get past” compile errors. This is the worst habit. If you truly don’t know the type, use unknown. Enable the @typescript-eslint/no-explicit-any rule to catch these cases.
Casting unknown with as instead of narrowing. data as Order only reassures the compiler without checking anything at runtime. Essentially, it’s just as dangerous as any.
const data: unknown = JSON.parse('{"id": 1}');
const order = data as Order; // compile OK
order.customerName.trim(); // TypeError at runtime
Using unknown for everything. unknown is for system boundaries: user input, API responses, catch, deserialized data. Inside your domain, use specific types. A function calculateTotal(items: unknown) is poor design.
Confusing never with void. void means the function returns but has no useful value. never means the function never returns. A regular log() function should be void, not never.
Forgetting default in switch. Without assertNever, TypeScript only reports an error if the function has an explicit return type and noImplicitReturns is enabled. Even then, you lose the safety net at runtime.
Note:
anystill has legitimate uses. That’s when incrementally migrating a large JavaScript codebase, or when writing declarations for a library where types are truly too dynamic. Isolate it tightly and leave a comment explaining why.
8. Conclusion
These three types have three distinct roles:
- Use
unknownfor data of uncertain type at system boundaries, then narrow withtypeof,instanceof,in, or a type guard. - Use
neverfor functions that always throw, for exhaustive checking withassertNever, and to filter unions in conditional types. - Avoid
anyunless you have a specific reason and have isolated it.
The practical rule is simple. When about to type any, try unknown first. If the compiler reports an error, those are exactly the places where you need to check your data.