Embracing TypeScript's Quirks: Handling Errors Like a Pro

Hey there, fellow developers! Let's talk about this.

So, we've got some code here—a little snippet of JavaScript. It's all about error handling and reporting. Good stuff, right? But hold on, there's a twist. We're going to throw TypeScript into the mix and see what happens.

First, let's take a look at this piece of JavaScript code:

const handleErrorMessage = ({ message }) => {
  // send the error to our logging service...
}

try {
  throw new Error('Oh no!')
} catch (error) {
  // we'll proceed, but let's report it
  handleErrorMessage({ message: error.message })
}

Looks fine, doesn't it? But now, let's spice things up with some TypeScript:

const handleErrorMessage = ({ message }: { message: string }) => {
  // send the error to our logging service...
}

try {
  throw new Error('Oh no!')
} catch (error) {
  // we'll proceed, but let's report it
  handleErrorMessage({ message: error.message })
}

Uh-oh, something's not happy here. See that handleErrorMessage call? Specifically, it's the error.message part. TypeScript doesn't like it. Why, you ask? Well, TypeScript defaults our error type to unknown now. And you know what? That's exactly what an error is—unknown. When it comes to errors, you can't make any guarantees about the types that are thrown. It's like the .catch(error => {}) of a promise rejection—you can't provide a rejected value type.

When it comes to throwing things, you can throw just about anything:

throw 'What the!?'
throw 7
throw {wut: 'is this'}
throw null
throw new Promise(() => {})
throw undefined

Seriously, anything of any type can be thrown. So, how do we handle this in TypeScript? Can we add a type annotation to say that this code will only throw an error? Well, not so fast! TypeScript will give you a compilation error:

Catch clause variable type annotation must be 'any' or 'unknown' if specified. ts(1196)

Why? Because JavaScript can be funny sometimes. A third-party library could monkey-patch the error constructor and throw something different:

Error = function () {
  throw 'Flowers'
} as any

So, what's a developer to do? Well, the best they can! Here's a solution:

try {
  throw new Error('Oh no!')
} catch (error) {
  let errorMessage = 'Unknown Error'
  if (error instanceof Error) errorMessage = error.message
  // we'll proceed, but let's report it
  reportError({message: errorMessage})
}

Now TypeScript isn't yelling at us, and more importantly, we're handling cases where it could be something completely unexpected. But hey, we can do even better:

try {
  throw new Error('Oh no!')
} catch (error) {
  let errorMessage
  if (error instanceof Error) errorMessage = error.message
  else errorMessage = String(error)
  // we'll proceed, but let's report it
  reportError({message: errorMessage})
}

Here, if the error isn't an actual Error object, we'll just stringify it and hope it becomes something useful.

But why stop there? Let's turn this into a utility for all our catch blocks:

function getErrorMessage(error: unknown) {
  if (error instanceof Error) return error.message
  return String(error)
}

const reportError = ({message}: {message: string}) => {
  // send the error to our logging service...
}

try {
  throw new Error('Oh no!')
} catch (error) {
  // we'll proceed, but let's report it
  reportError({message: getErrorMessage(error)})
}

Voila! This has been helpful for me in my projects, and I hope it helps you too.

Update: I've received some great suggestions from Nicolas and Jesse. Nicolas proposed handling situations where the error object isn't an actual error, while Jesse suggested stringifying the error object if possible. So, let's combine those suggestions:

type CustomError = {
  message: string
}

function isCustomError(error: unknown): error is CustomError {
  return (
    typeof error === 'object' &&
    error !== null &&
    'message' in error &&
    typeof (error as Record<string, unknown>).message === 'string'
  )
}

function toCustomError(maybeError: unknown): CustomError {
  if (isCustomError(maybeError)) return maybeError

  try {
    return new Error(JSON.stringify(maybeError))
  } catch {
    // fallback in case there's an error stringifying the maybeError
    // like with circular references, for example.
    return new Error(String(maybeError))
  }
}

function getErrorMessage(error: unknown) {
  return toCustomError(error).message
}

Handy, isn't it?

In conclusion, the key takeaway here is not to dismiss compilation errors or warnings from TypeScript just because you think something is impossible. Most of the time, the unexpected can happen, and TypeScript does a pretty good job of forcing you to handle those unlikely cases. And guess what? You'll probably find that they're not as unlikely as you initially thought. Keep coding and embracing TypeScript's funny bits!