Right, so you’ve decided to use try/catch in TypeScript. Good for you. It’s the responsible choice. You write your beautiful, type-safe code, you wrap the risky operation, and then you go to handle the error in the catch block. You type e and… wait a minute. What is this unknown nonsense?

I can see your face. You were expecting an Error object, or maybe a string if some barbaric library threw one. Instead, TypeScript gives you this cosmic shrug. You feel like you’ve been robbed. “I know this function throws an Error!” you yell at your IDE. The IDE does not care.

Well, my friend, let me tell you: this isn’t a limitation of TypeScript. This is its brilliant, pedantic, absolutely correct genius showing through. TypeScript is the only friend brave enough to tell you, “You have no idea what that function will actually throw at runtime, and you’re a fool to think you do.”

Why unknown and Not any?

This is the first, most important thing to understand. TypeScript could have taken the easy way out and typed all caught exceptions as any. That would have been a disaster. any is a free pass; it says, “I opt out of type checking. Do whatever you want.” You could write e.thisMethodDefinitelyExists() and TypeScript would just nod and compile it, setting you up for a runtime error in your error handler—which is the most ironic kind of failure.

unknown is different. It’s safe. It’s TypeScript’s way of saying, “I have something, but I won’t let you use it until you prove to me what it is.” It forces you to perform type narrowing before you can do anything useful with the error. This is a feature, not a bug. It’s the compiler protecting you from your own (well-intentioned) ignorance.

The Absolute Chaos of the Throw Statement

Here’s the cold, hard truth that makes this whole thing necessary: in JavaScript, you can throw literally anything.

// A sampling of perfectly valid, yet utterly horrifying, throws
throw new Error("The classic"); // Good
throw "A string error? Why?!"; // Bad
throw 404; // Worse
throw { code: 500, message: "Internal Server Fart" }; // The worst
throw null; // Actively malicious

A function written in plain JavaScript, maybe from a third-party library you npm installed in a moment of weakness, can throw any of these. When TypeScript calls this function, it has no runtime guarantees. Its type signature might say it throws an Error, but that’s just a pretty lie we tell ourselves. The type system cannot save you from the chaos of the dynamic world at the boundaries of your code. So, at the one place where chaos is guaranteed to erupt—the catch clause—it forces you to deal with that chaos explicitly.

How to Actually Deal with an unknown Error

So your error is unknown. Great. Now what? You become a detective. You interrogate the value until you figure out what it is. This is where type guards become your best friend.

The most robust and common pattern is to check if the caught value is an instance of the Error class.

try {
  riskyOperation();
} catch (e) {
  // e is unknown
  if (e instanceof Error) {
    // Now, and ONLY now, is e typed as Error
    console.error(e.message);
    // You can also access e.stack, e.name, etc.
  } else {
    // But what if it's not? You have to handle this!
    console.error("Something went horribly wrong, and we don't even have an Error object:", e);
  }
}

But what if you’re dealing with an API that throws error-like objects that aren’t actual Error instances? This is surprisingly common. For those, you need a more general type guard.

function isErrorWithMessage(e: unknown): e is { message: string } {
  return (
    typeof e === 'object' &&
    e !== null &&
    'message' in e &&
    typeof (e as any).message === 'string'
  );
}

try {
  anotherRiskyOperation();
} catch (e) {
  if (isErrorWithMessage(e)) {
    // Now we know it's an object with a string 'message' property
    console.error(e.message);
  } else {
    // Log the whole thing as a last resort
    console.error("An unknown error value was thrown:", e);
  }
}

The One-Liner You’re Tempted To Write (And Why It’s a Trap)

I know what you’re thinking. “This is boilerplate. I’ll just cast it and be done with it.”

try {
  riskyOperation();
} catch (e) {
  const err = e as Error; // <-- The Dark Path
  console.error(err.message);
}

Don’t. Just don’t. You are lying to the compiler, and by extension, to yourself and your future self who has to debug this at 2 AM. If that library ever decides to throw a string instead of an Error, your err.message will be undefined, and you’ll get a cryptic Cannot read properties of undefined error that completely obscures the original, simple problem. The type cast is a siren song—it looks like an easy shortcut but leads directly to harder-to-debug code.

Embrace the unknown. The few lines of validation it forces you to write are the strongest, most robust error handling code you’ll ever write. It’s the difference between assuming everything will be fine and actually being prepared for the inevitable chaos of the real world.