Fastkit

catcher

Custom classes for Type-safe handling of exceptions in your application.

Installation

npm install @fastkit/catcher

Why catcher?

Exceptions occur in applications all the time. There are also times when it is necessary to intentionally throw exceptions.
Either way, these exceptions need to be handled at some final layer, and I would like to see, on a per-package or per-application basis, a consistent schema where the parameters of this exception are abstracted and referenced both when handling the exception and when throwing the exception.
catcher is a builder of exception classes dedicated to your application to meet this need in your application or package development.

Assumed Philosophy

catcher basically assumes the following philosophy. Of course, there is no problem to use it without assuming the following.
  • Handle any exceptions that occur together at a layer as close to the call of the process as possible
  • The service logic at the end of the line, etc., either supplements the exception as little as possible, or supplements it, creates the necessary exception instance, and throws it to the caller.
  • Have rules for exception handling and schemes during constructs by abstracting the exception situation according to the requirements and nature of the application.
    • Requested resource not found.
    • No response was received from the opposing system.
    • There was no authorization for the requested resource.

Basic usage flow

1. Designing Exceptions in Your Application

e.g.
  • All exceptions have a numerical status that follows the HTTP status code, and the status alone can be used to roughly distinguish the status of the exception.
  • Since axios is mainly used in the application, the message and status are motivated when constructs are based on the axios error class

2. Define exception classes according to design

e.g.
import { build, axiosErrorResolver } from '@fastkit/catcher';

type YourAppErrorInput = string | number | { [key: string]: any };

interface YourAppErrorNormalized {
  name: string;
  message: string;
  stack?: string;
  status: number;
}

export class YourAppError extends build({
  resolvers: [axiosErrorResolver],
  normalizer: (resolvedData) => {
    return (input: YourAppErrorInput): YourAppErrorNormalized => {
      let { name, message, status } = resolveYourAppErrorInput(input);
      let stack: string | undefined;
      const { axiosError, nativeError } = resolvedData;
      if (axiosError) {
        name = axiosError.name;
        message = axiosError.message;
        stack = axiosError.stack;
        const { response } = axiosError;
        if (response) {
          status = response.status;
        }
      } else if (nativeError) {
        name = nativeError.name;
        message = nativeError.message;
        stack = nativeError.stack;
        if (nativeError.name === 'PayloadTooLargeError') {
          status = 413;
        }
      }
      const statusResolved = resolveStatusMessage(status);
      status = statusResolved.status;
      if (!message) {
        message = statusResolved.message;
      }
      return {
        name,
        message,
        stack,
        status,
      };
    },
  },
});ts

3. Use custom exceptions you define

e.g.
import { YourAppError } from './error';

export async function someFn() {
  try {
    const result = await axios.get('/some/path/');
    return result.data;
  } catch (_err) {
    const err = YourAppError.from(_err);
    if (err.status === 404) {
      alert('Not found...!!!');
      return;
    }
    throw err;
  }
}ts

build
function

# Signature
<Resolvers extends AnyResolvers, Normalizer extends AnyNormalizer<Resolvers>>(opts: CatcherBuilderOptions<Resolvers, Normalizer>): CatcherConstructor<Resolvers, Normalizer>

Generate exception catcher constructor

  • The instance type is determined by the custom resolver and normalizer specified in the options
# Parameters
NameTypeDefault
opts*CatcherBuilderOptions<Resolvers, Normalizer>
# Return Type
CatcherConstructor<Resolvers, Normalizer>

Exception catcher constructor

CatcherBuilderOptions
class

Catcher constructor generation options

defaultName
optional

undefined | string

Default name if the name cannot be resolved from the supplemented exception

defaultMessage
optional

undefined | string

Message to use when nothing else produced one

Applied last, after the normalizer and after the native error's own message, and only when what is left is empty.

Set it. Without it there is no guarantee that a catcher has a message, and the failure is quiet rather than loud: a normalizer that returns no message for an exception it did not recognise leaves the instance with the empty string an Error is born with, so toJSON() reports "message": "" -- not an absent field a log pipeline can spot, but a present one that reads like a real, empty message.

build({
  defaultName: 'AppError',
  defaultMessage: 'Something went wrong',
  resolvers,
  normalizer,
});

The string belongs to the application, not to this package, so there is no default -- it is what a user may end up reading. Development warns once per catcher when an instance is built without a message and this is unset.

resolvers
optional

undefined | Resolvers

List of Exception Resolver

normalizer

Normalizer

Exception Normalizer

A method to finally normalize the set of values resolved by the resolver

CatcherConstructor
class

Exception catcher constructor

  • The first thing to do is to generate this constructor in your application using the build method
  • Instance properties are extended by type arguments at creation
# Signature
(errorInfo: Parameters<ReturnType<Normalizer>>[0]): Catcher<Resolvers, ReturnType<ReturnType<Normalizer>>> & ReturnType<ReturnType<Normalizer>>

Create an error instance based on the error information.

# Parameters
NameTypeDefault
errorInfo*Parameters<ReturnType<Normalizer>>[0]
# Return Type
Catcher<Resolvers, ReturnType<ReturnType<Normalizer>>> & ReturnType<ReturnType<Normalizer>>

Create an error instance based on the error information.

create
function

# Signature
(errorInfo: Parameters<ReturnType<Normalizer>>[0]): Catcher<Resolvers, ReturnType<ReturnType<Normalizer>>> & ReturnType<ReturnType<Normalizer>>

Create an error instance based on the error information.

# Parameters
NameTypeDefault
errorInfo*Parameters<ReturnType<Normalizer>>[0]
# Return Type
Catcher<Resolvers, ReturnType<ReturnType<Normalizer>>> & ReturnType<ReturnType<Normalizer>>

Create an error instance based on the error information.

from
function

# Signature
(unknownException: unknown, overrides?: Partial<ReturnType<ReturnType<Normalizer>> & ErrorImplements>): Catcher<Resolvers, ReturnType<ReturnType<Normalizer>>> & ReturnType<ReturnType<Normalizer>>

Create an error instance based on an unknown exception. The fields of the error instance can be overwritten by specifying an object as the second argument.

# Parameters
NameTypeDefault
unknownException*unknown
overridesPartial<ReturnType<ReturnType<Normalizer>> & ErrorImplements> | undefined
# Return Type
Catcher<Resolvers, ReturnType<ReturnType<Normalizer>>> & ReturnType<ReturnType<Normalizer>>

Create an error instance based on an unknown exception. The fields of the error instance can be overwritten by specifying an object as the second argument.

fromAsync
function

# Signature
(unknownException: unknown, overrides?: Partial<ReturnType<ReturnType<Normalizer>> & ErrorImplements>): Promise<Catcher<Resolvers, ReturnType<ReturnType<Normalizer>>> & ReturnType<ReturnType<Normalizer>>>

from, awaiting every resolver

Some of what an exception carries can only be reached asynchronously -- a Response hands out its body through a promise and no other way. A resolver cannot deliver that to a synchronous constructor: the instance has to exist by the time it is thrown, which is before the body could arrive.

This entry point awaits the resolvers first and normalizes afterwards, so the normalizer sees everything. Prefer it wherever you can await -- a catch in an async function, which is where fetch errors live:

try {
  await api.getUser(id);
} catch (e) {
  throw await AppError.fromAsync(e);
}

Nothing else changes: you still hand it an unknown exception and let the resolvers work out what it is.

# Parameters
NameTypeDefault
unknownException*unknown
overridesPartial<ReturnType<ReturnType<Normalizer>> & ErrorImplements> | undefined
# Return Type
Promise<Catcher<Resolvers, ReturnType<ReturnType<Normalizer>>> & ReturnType<ReturnType<Normalizer>>>

from, awaiting every resolver

Some of what an exception carries can only be reached asynchronously -- a Response hands out its body through a promise and no other way. A resolver cannot deliver that to a synchronous constructor: the instance has to exist by the time it is thrown, which is before the body could arrive.

This entry point awaits the resolvers first and normalizes afterwards, so the normalizer sees everything. Prefer it wherever you can await -- a catch in an async function, which is where fetch errors live:

try {
  await api.getUser(id);
} catch (e) {
  throw await AppError.fromAsync(e);
}

Nothing else changes: you still hand it an unknown exception and let the resolvers work out what it is.

createAsync
function

# Signature
(errorInfo: Parameters<ReturnType<Normalizer>>[0]): Promise<Catcher<Resolvers, ReturnType<ReturnType<Normalizer>>> & ReturnType<ReturnType<Normalizer>>>

create, awaiting every resolver

See fromAsync.

# Parameters
NameTypeDefault
errorInfo*Parameters<ReturnType<Normalizer>>[0]
# Return Type
Promise<Catcher<Resolvers, ReturnType<ReturnType<Normalizer>>> & ReturnType<ReturnType<Normalizer>>>

create, awaiting every resolver

See fromAsync.

Catcher
class

Caught exception instances

isCatcher
readonly

true

It is a catcher instance

data
readonly

CatcherData<T>

What the normalizer returned -- and the only thing that is serialized

toJSON() emits this and nothing else, so this is what leaves the process: what reaches a log, an error report, a response body. Its fields are also defined onto the instance, which is where err.status comes from.

Not to be confused with resolvedData, which is one layer earlier and is not serialized. The two names are one word apart and the difference between them is what is safe to keep -- see resolvedData before copying anything across.

resolvedData
readonly

UnionToIntersection<ReturnType<MergeParametersAndReturnTypes<[(source: unknown) => NativeErrorOverrides | undefined, ...Resolvers]>>>

What the resolvers extracted -- never serialized

Everything they found, in full, because none of it leaves on its own: toJSON() emits data, which is what the normalizer returned. That is the boundary, and it is deliberate -- a resolver may hold the whole response precisely because holding it costs nothing.

So copy out of here deliberately, never wholesale. What a response carries is what the server sent:

  • set-cookie -- session and refresh tokens, and a 401 is exactly when those get rotated
  • a url that may hold a signed-URL signature or a ?token=
  • a body that may hold far more than the message you were after
// Fine: a code and a message.
return { code: 'HTTP_ERROR', message: response.json?.message };

// Puts the session cookie, the signed URL and the whole body wherever
// this error is logged.
return { ...response };

This package's own README made the second mistake, which is the reason this warning is here rather than in the documentation alone.

source
readonly
optional

undefined | Catcher<Resolvers, T>

Catcher instance generated from original exception source before override

  • Only set if override is specified at instance creation

histories
readonly

Catcher<Resolvers, T>[]

List of extension sources for own instance

  • The younger the index, the closer the source is to you.

messages
readonly

string[]

全ての歴史を辿ったメッセージのリスト

  • If multiple identical messages are detected, they are filtered to avoid duplicates

toJSON
function

# Signature
(): ErrorImplements & CatcherData<T> & { messages: string[]; }

Obtain as a JSON object

# Parameters
NameTypeDefault
# Return Type
ErrorImplements & T & Partial<ErrorImplements> & { $__catcher: true; } & { messages: string[]; }

Obtain as a JSON object

toJSONString
function

# Signature
(indent?: number | boolean): string

Obtain as JSON string

# Parameters
NameTypeDefault
indentnumber | boolean | undefined
number of indentations
# Return Type
string

Obtain as JSON string