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 return any. This is the most common source of any leaks 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
  • typeof for primitive types: string, number, boolean…
  • instanceof for classes.
  • in checks if a property exists. Since TypeScript 4.9, after "id" in value, value has type object & Record<"id", unknown>.
  • You must check value !== null because typeof 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:

  • unknown is the safe top type. Everything is unknown.
  • never is the bottom type. never is a subtype of every type.
  • any is 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: any still 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 unknown for data of uncertain type at system boundaries, then narrow with typeof, instanceof, in, or a type guard.
  • Use never for functions that always throw, for exhaustive checking with assertNever, and to filter unions in conditional types.
  • Avoid any unless 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.

typescripttype-safetyunknownneverany