Type-safe error handling without 3rd party libraries

Viewed 595

I understand that the reason why typesafe error handling is not a thing in ts is because we are missing the throws clause from functions. So because of this whenever I do this:

function mighThrow(input: number): void {
  if (input === 1) {
    throw new TypeError('cannot be one')
  } else if(input === 2) {
    throw new SyntaxError('invalid syntax: 2')
  }
  console.log('all good', input)
}

I can't catch the errors with accurate typing:

try {
  mighThrow(1)
} catch(e) {
  // e is any, even though it could be TypeError | SyntaxError
}

The issue is the same with Promises, the catcher functions argument is hardcoded any:

interface Promise<T> {
  then<TResult1 = T, TResult2 = never>(onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null): Promise<TResult1 | TResult2>;
  catch<TResult = never>(onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | undefined | null): Promise<T | TResult>;
}

The argument of catch is any.

So my question is how can I get around this? If I'm a library author, is there a typesafe way to help users figure out what exactly are they catching? I'm aware of solutions like adding an Either type and calling it a day, but this enforces handling the error on the spot, which just turns the code into something similar to as if the language would have checked exceptions.

1 Answers

Since there's currently no throws in TypeScript, any suggestion is going to look like a workaround. You're looking for a solution that doesn't result in any overhead for the user who wants to ignore errors, but which allows the users who do want to catch errors to get stronger types for the errors.

How about something like this (which requires TS3.1 and up to work):

function mighThrow(input: number): void {
  if (input === 1) {
    throw new TypeError('cannot be one')
  } else if (input === 2) {
    throw new SyntaxError('invalid syntax: 2')
  }
  console.log('all good', input);
}
mighThrow.throws = undefined! as TypeError | SyntaxError;

Here we have used a function property declaration to declare a phantom property called throws. A phantom property is one which doesn't really exist at runtime, but the compiler thinks it does, so the compiler maintains extra type information. In this case, undefined! as TypeError | SyntaxError is just undefined at runtime, but the compiler thinks mighThrow has a throws property of type TypeError | SyntaxError.

Now when people want to catch errors, they can use a helper function:

type ThrowsType<T> = T extends { throws: infer E } ? E : unknown;
const asTypedError = <FS extends Function[]>(e: any, ...f: FS): ThrowsType<FS[number]> => e;

like this:

try {
  const x = mighThrow(123);
} catch (err) {
  const typedErr = asTypedError(mighThrow, err);
  // typedErr is now TypeError | SyntaxError
  if (typedErr instanceof TypeError) {
    typedErr; // TypeError
  } else {
    typedErr; // SyntaxError
  }       
}

It's not perfect by any means. It requires the error catcher to keep track of which functions were called themselves. A try-catch block that surrounds multiple functions would need to pass in all those functions to asTypedError():

declare const alsoMightThrow: { throws: URIError } & ((input: string) => number);
try {
  const x = mighThrow(alsoMightThrow("hey"));
} catch (err) {     
  const typedErr = asTypedError(err, mighThrow, alsoMightThrow); // 
  // typedErr: TypeError | SyntaxError | URIError
}

And nothing actually guarantees that the caught error is of the type(s) declared in the throws property... it's up to the library maintainer to get that right (which is hard because so many things can throw a TypeError at runtime). Conversely, most functions will not have such a throws property, so there's not a lot of incentive for the average developer to use this type of error catching. If someone puts a non-throws function there, asTypedError will output an unknown error, which is hardly more useful than any. You'd have to educate library users how to use this thing, at which point it could be easier just to document the heck out of your function and error catchers can do their own instanceof checks to narrow from any. It's a workaround for a missing language feature, and it looks like it. ‍♀️


Oh well, hope that helps. Good luck!

Related