TypeScript – any, unknown, never and void

July 16, 20268 min readUpdated 9/5/2026

Four types here do not describe ordinary values. Two of them look interchangeable and are not — choosing between any and unknown is one of the few decisions in this language that genuinely changes how safe a codebase is.

any turns the checker off

any means "stop checking this". Every operation on an any is allowed, and the result is another any.

let response: any = await fetch('/api/orders').then((r) => r.json());

response.total.toFixed(2);        // fine
response.nonsense.deeply.nested;  // also fine
response();                       // still fine

None of those are checked, and the last two throw at runtime. That is not a bug in TypeScript; it is precisely what you asked for.

The property that makes any genuinely dangerous is that it spreads. An any assigned to something else makes that thing effectively unchecked too, and because no error is ever reported, nothing tells you how far it went. One any at an API boundary can quietly disable type checking across a whole feature.

It arrives in three ways. You wrote it. You imported an untyped package. Or — most often — noImplicitAny is off and a parameter you forgot to annotate became any silently. That last one is why strict matters.

There are legitimate uses: a genuinely dynamic value, a migration in progress, a type so awkward that describing it costs more than it returns. What makes them legitimate is that they are deliberate. If you must, leave a note saying so — a bare any is indistinguishable from one somebody gave up on.

unknown is the one you want

unknown is any's careful twin. Anything is assignable to it, and you can do nothing with it until you have proved what it is.

// This does not compile.
function report(err: unknown) {
  console.log(err.message);
  //              ~~~~~~~
  // 'err' is of type 'unknown'.
}

That error is the feature. The value might be a string, a number, or null — nothing guarantees a .message. To use it, prove something first:

function report(err: unknown) {
  if (err instanceof Error) {
    console.log(err.message); // fine — err is an Error in here
  }
}

Inside that block err is an Error, so .message is allowed. This is narrowing, and it is the reason unknown is usable rather than merely safe.

Where the app uses it

Two places, and they are the two places every application has.

Errors. Anything can be thrown in JavaScript — a string, a number, an object that is not an Error — so a caught value is genuinely unknown. The pizza app's Redux layer takes it as such:

export function toApiFailure(err: unknown, fallback: string): ApiFailure {
  if (err instanceof ApiError) {
    return { message: err.message, fieldErrors: err.fieldErrors() };
  }
  return { message: err instanceof Error ? err.message : fallback, fieldErrors: {} };
}

Read the shape of it: two proofs and a fallback. If it is the app's own ApiError, use the structured body. If it is any other Error, use the message. Otherwise use the caller's fallback string, because there is nothing trustworthy to show.

That third branch is what unknown buys you. With any, the natural code is err.message, which produces undefined for a thrown string and shows the user a blank error box.

The Angular half solves the same problem the same way:

export function errorMessage(error: unknown, fallback: string): string {
  if (error instanceof ApiError || error instanceof Error) {
    return error.message || fallback;
  }
  return fallback;
}

Request bodies. The other direction — a value the function will not inspect, only forward:

interface RequestOptions {
  method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
  body?: unknown;

body is unknown rather than any because the HTTP wrapper has no business knowing its shape — it only passes it to JSON.stringify. Typing it any would say the same thing while also volunteering to stop checking anything that touches it.

Downgrading an any you did not choose

Some any values are handed to you. JSON.parse is the one everybody meets: it is declared to return any, because it genuinely cannot know better.

That means the moment you parse a response, an unchecked value is loose in your program. The app closes it off on the same line:

const text = await response.text();
const parsed = text ? (JSON.parse(text) as unknown) : null;

as unknown looks like it is doing nothing. It is doing the most valuable thing in the function: converting a value the compiler has stopped checking into one it insists you check. From here on, parsed cannot be used without a deliberate decision about what it is — which, a few lines later, is exactly what happens:

  if (!response.ok) {
    const errorBody = parsed as ApiErrorBody | null;

That second assertion is still a claim rather than a check — see lesson 16 — but it is a claim made once, in the one file that talks to the network, instead of implicitly everywhere the response travels.

The general move is worth remembering: when a library hands you any, widen it to unknown immediately, then narrow deliberately.

object, {} and Object are not what you want

Three types look like they might mean "some object" and all three disappoint.

let a: object;   // any non-primitive. No properties are readable.
let b: {};       // anything except null and undefined. Yes, including numbers.
let c: Object;   // effectively the same as {}. Never write this.

{} is the surprising one — it means "not null or undefined", so const x: {} = 42 compiles. It is not a description of an object at all.

object at least excludes primitives, but you still cannot read any property off it, which makes it about as useful as unknown and considerably less honest.

If you want "an object with unknown properties", write an index signature, which lesson 6 covers:

function fieldErrors(): Record<string, string> {
  const result: Record<string, string> = {};

That is what the app does for its per-field validation messages, and it is readable: string keys, string values, and the compiler will hold you to both.

catch already gives you unknown

With strict on, a caught value is unknown automatically:

try {
  await placeOrder(cart);
} catch (err) {
  // err is unknown, not any
  setError(failureMessage(err, 'Could not place your order.'));
}

This changed in TypeScript 4.4, via useUnknownInCatchVariables. It is included in strict, and it is why the two helpers above exist at all — a codebase that catches errors in twenty components wants one function that turns an unknown into a displayable string, rather than twenty slightly different guesses.

never: the type with no values

never is the empty type. No value has it, which sounds useless and is not.

It is what a function that does not finish returns:

function fail(message: string): never {
  throw new Error(message);
}

Not voidvoid means "returns, with nothing useful". This never returns at all, and saying so lets TypeScript understand that code after a call to it is unreachable.

You will also meet it as the result of an impossible narrowing:

function check(value: string) {
  if (typeof value === 'number') {
    value; // value is never — a string cannot also be a number
  }
}

That looks like a curiosity. It is actually the most useful thing in this lesson, because it turns into an exhaustiveness check: narrow a union down through every case and the leftover is never, so assigning it to a never variable compiles only while every case really is handled. Add a member to the union and that line fails. The full pattern is in Narrowing.

One more property, which explains behaviour you will otherwise find baffling: never vanishes from a union. string | never is just string, because adding "no values at all" to a set of values changes nothing.

That is why a conditional type that filters a union works at all, and why a union you expected sometimes collapses to something narrower than you wrote. It is also the mechanism behind Exclude, in lesson 14.

void: returns nothing worth having

void is the return type of a function whose result you should ignore.

export const tokenStore = {
  get: (): string | null => localStorage.getItem(TOKEN_KEY),
  set: (token: string) => localStorage.setItem(TOKEN_KEY, token),
  clear: () => localStorage.removeItem(TOKEN_KEY),
};

set and clear return void — inferred, not written. get returns string | null, because localStorage gives back null for a missing key and the type says so.

Two things about void catch people out.

It is not undefined. A void function may return anything at all; the type is a promise about the caller, saying the value will not be used. A function typed (): undefined must actually return undefined.

A function returning something is assignable to a void callback type. This looks like a hole and is deliberate:

const onSelect: (product: Product) => void = (product) => items.push(product);

push returns a number; the callback type says void; it compiles. It has to, or you could never pass arr.push or any other value-returning function as a handler. The contract is only that the caller will ignore whatever comes back.

Which to use

TypeUse it when
unknownthe value could be anything and you will check. The default choice.
anyyou have decided to stop checking, on purpose, with a comment.
neverthis cannot happen — throwing functions, exhaustive switches.
voidthe return value is not meaningful.

The short version: when you are about to write any, write unknown instead. It will make the compiler ask you a question, and the answer to that question is almost always a real bug you were about to ship.

If you want one measure of how healthy a TypeScript codebase is, count the anys. Not because every one is wrong — some are the right call — but because a codebase with fifty of them has stopped choosing. The ESLint rule no-explicit-any exists for teams that want the count enforced rather than monitored; setting it to warn rather than error is a reasonable middle position, since it makes each one a visible decision without blocking work.

The same goes for @ts-ignore and its better-behaved replacement @ts-expect-error. Prefer the second: it suppresses the error on the next line and fails the build once that line stops having an error, so a suppression cannot outlive the problem it was hiding. A @ts-ignore written in 2024 is still silently ignoring something today.

Next

Arrays and Tuples — collections, and the difference between a list of things and a fixed-length row.