RizTech Academy logo
RizTech Academy
Writing TypeScript Worth ReadingLesson 1 of 525 min

unknown over any, and never lying to the compiler

You now know enough TypeScript to make the compiler happy. This module is about the gap between code that compiles and code a team is glad you wrote — the difference that decides whether you are worth hiring beyond a first task. It starts with the single most important habit: never lie to the compiler, and prefer unknown over any every time.

any is a lie

Every any you write is a promise: "trust me, I know what this is, stop checking." When the promise is false — and it often is — you have reintroduced exactly the runtime crashes TypeScript exists to prevent:

function process(data: any) {
  return data.user.name.toUpperCase();   // compiles fine
}

process({ user: null });   // runtime crash: Cannot read properties of null
process("hello");          // runtime crash: hello.user is undefined

any made every line compile and every line a runtime bug. This is the core problem: any does not make a type problem go away; it hides it until runtime. A codebase sprinkled with any gets none of TypeScript's benefit — it is JavaScript with extra ceremony, and it fails in production exactly where the types were switched off.

The instinct to reach for any almost always comes from one place: a red error you want gone. any makes the error disappear — but the bug it was warning about is still there, now invisible. Making an error message go away is not the same as fixing the problem, and any is the tool for the former disguised as the latter.

unknown — honest about not knowing

When you genuinely do not know a value's type — external data, a dynamic value — the honest type is unknown, not any. Both accept any value; the difference is what happens next:

function process(data: unknown) {
  data.user.name;   // error: 'data' is of type 'unknown'

  if (typeof data === "object" && data !== null && "user" in data) {
    // now you have PROVEN something about data before using it
  }
}

unknown says "I do not know what this is, so make me check." It permits nothing until you narrow — so it keeps the safety any throws away. The bug that any hid (accessing .user on something that might not have it) becomes a compile error with unknown, forcing you to handle it. unknown is the right type almost everywhere you were tempted by any, because it captures "I do not know the type yet" without abandoning the guarantee.

The mechanical rule: when you want to write any, write unknown and then narrow. It is a few more characters and it keeps the compiler on your side.

When any is genuinely acceptable — rarely, and deliberately

"Never any" as an absolute is itself a mistake (a myth the first module cleared up). There are a few honest uses:

  • A migration in progress. Adding TypeScript to a large JavaScript codebase, a temporary any at an unconverted boundary is pragmatic — with a // TODO: type this comment and a plan to remove it.
  • A genuinely dynamic value that resists typing, where unknown plus narrowing would be disproportionate machinery for a tiny, contained case — and even then, unknown is usually still better.
  • A third-party type that is wrong, where you must override it locally. Prefer a precise override, but a commented any is sometimes the pragmatic escape.

The common thread: deliberate, localised, and commented. An any that is a conscious decision, confined to one spot, with a comment explaining why, is defensible. An any reached for reflexively to silence an error is not. If you cannot write a one-line comment justifying an any, you should not be writing it.

Configure the compiler to catch lies

Two settings turn "prefer not to lie" into "cannot lie by accident":

  • strict: true — enables the whole family of strict checks, including noImplicitAny (an un-annotated parameter, which would be a silent any, becomes an error) and strictNullChecks. Turn strict mode on from day one — the strict-mode lesson in the real-projects module is emphatic about this.
  • An ESLint rule — @typescript-eslint/no-explicit-any flags every explicit any, so each one is a conscious choice you must acknowledge (or disable with a comment). Many teams enable it, precisely to make any deliberate rather than habitual.

With these on, an accidental any is caught, and a deliberate one is visible — which is exactly the posture you want.

The unknown-based validation pattern

The place you should use unknown constantly is the boundary — external data entering your program:

async function fetchUser(id: number): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  const data: unknown = await response.json();   // unknown — the API could send anything

  if (!isUser(data)) {
    throw new Error("Invalid user data from API");
  }
  return data;   // narrowed to User by the guard — now safe
}

function isUser(value: unknown): value is User {
  return typeof value === "object" && value !== null
    && "id" in value && "name" in value;
}

response.json() returns any by default (a historical wart) — the first thing to do is assign it to unknown, which forces validation before you trust it. The type guard (isUser) narrows it, and only then do you return a User. This is the honest way to handle external data: treat it as unknown, validate it, then use the validated type — never annotate incoming data with the shape you hope it has and skip the check (the "TypeScript does not validate data" myth). A schema library like Zod (real-projects module) does this validation for you, and returns a typed value.

Check your work

Why any is a lie. It tells the compiler to stop checking, reintroducing the runtime crashes TypeScript prevents — hiding the bug until runtime.

Where the instinct for any comes from, and why it is wrong. Silencing a red error — but that hides the warned-about bug rather than fixing it.

Why unknown is the honest alternative. It accepts any value but permits nothing until you narrow — keeping the safety any discards.

The mechanical rule. When tempted to write any, write unknown and narrow.

When any is acceptable. Rarely — a migration boundary, a genuinely dynamic contained value, a wrong third-party type — and always deliberate, localised, and commented.

The test for an acceptable any. Can you write a one-line comment justifying it? If not, do not write it.

The two settings that catch lies. strict: true (including noImplicitAny) and the ESLint no-explicit-any rule.

The external-data pattern. Treat incoming data as unknown, validate it (a guard or Zod), then use the validated type.

Practice

  1. Write a process(data: any) that crashes at runtime on bad input. Confirm it compiles despite the bug.
  2. Change any to unknown and confirm the bug is now a compile error. Narrow before using the data.
  3. Take a red error you would fix with any and instead fix it properly (narrow, or correct the type). Compare.
  4. Enable noImplicitAny (or strict) and write an un-annotated parameter. Read the error.
  5. Enable the ESLint no-explicit-any rule and confirm every explicit any is flagged.
  6. Write the fetchUser boundary pattern: unknown from json(), a guard, then a validated return.
  7. For three anys you can find (or imagine), decide whether each is a justifiable deliberate use or a lie to silence an error.

Official documentation

Next: avoiding as and !, and what to do instead.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship