ehmicky/modern-errors

Typescript constructor example?

renzor-fist opened this issue · 6 comments

Hi there, love the library so far but am struggling with a basic use case in Typescript and am hoping you could provide guidance to help beef up the typescript example section.

I have a HashMismatchError that receives two props.

throw new HashMismatchError(
  `Hash mismatch: expected ${stored.hashValue} but got ${foundHashValue}`,
  {
    props: {
      foundHash: foundHashValue,
      storedHash: stored.hashValue,
    },
  },
)

Its defined like this:

export const HashMismatchError = ModernError.subclass("HashMismatchError", {
  custom: class extends ModernError {
    foundHash: string
    storedHash: string
    constructor(
      message: string,
      options: InstanceOptions<Plugins> | undefined,
    ) {
      super(message, options)
      // @ts-expect-error can't figure out how to type this
      this.foundHash = options?.props?.foundHash
      // @ts-expect-error can't figure out how to type this
      this.storedHash = options?.props?.storedHash
    }
  },
})

Could you provide any guidance on how to clean up the constructor function signature and hash variable assignments? It would help me and others understand how to use constructor functions with the library. Thanks for your awesome lib!

Hi @renzor-fist,

Thanks for reaching out. I'm glad you find the library useful!

The main issue is that props is a built-in feature with its own semantics. When adding custom constructor options, I would advise making them separate attributes, as opposed to setting them inside props.*, to avoid conflicting with props built-in behavior and types.

In other words, something along the following lines:

export const HashMismatchError = ModernError.subclass("HashMismatchError", {
  custom: class extends ModernError {
    foundHash: string | undefined
    storedHash: string | undefined
    constructor(
      message: string,
      options?: InstanceOptions & { foundHash?: string, storedHash?: string }
    ) {
      super(message, options)
      this.foundHash = options?.foundHash
      this.storedHash = options?.storedHash
    }
  },
})

In your case though, you do not need to set this.foundHash = ... in the constructor, because props are already automatically set on any error instance and correctly typed. I am guessing this might be because you simplified your example for the sake of illustration, is that correct? Maybe your original use case actually transforms the option values before setting them?

Thanks for the quick, detailed response @ehmicky . Maybe I'm using the library the wrong way. I have a function that throws

throw new HashMismatchError(
  `Expected x but got y`,
  {
    props: {
      foundHash,
      storedHash
    },
  },
)

Elsewhere, I'm trying to read err.storedHash from the error, but it's not typed as I would expect (even though its defined at runtime).

const sendHashMismatchMessage = (
  error: typeof HashMismatchError,
) => {
  if (error.foundHash) { // Property 'hashType' does not exist on type 'ErrorSubclassCore<[], {}, typeof custom>'
    // do something
  } 
}

It sounds like you're saying I should simply be instantiating HashMismatchError instead of using props like:

new HashMismatchError(`Expected x but got y`, foundHash, storedHash)

Is this correct?

Thanks for the additional information. There are several possible situations, let me explain each.


  1. When calling new HashMismatchError('...', { props }), the error instance props are correctly typed. This only applies to that specific error instance.

  1. When a given error class is expected to be instantiated with always the same props, and you want to use the type of any instance of it (as opposed to a specific one). For example, your sendHashMismatchMessage() expects any instances of HashMismatchError, which are always instantiated with foundHash.

The simplest way to do this is to pass props as an option when creating the error class.

export const HashMismatchError = ModernError.subclass("HashMismatchError", { 
  props: { foundHash: '', storedHash: '' }, 
})
type FoundHash = InstanceType<typeof HashMismatchError>["foundHash"] // This is correctly `string`

This assigns default values for those props with that given error class. It also types them.

If you just want to type the props but not assign any default values, you can use a type assertion on an empty object.

export const HashMismatchError = ModernError.subclass("HashMismatchError", { 
  props: {} as { foundHash: string, storedHash: string }, 
})

In either case, you can use a custom constructor if you want to perform some validation of those props, such as ensuring they are passed by users.

interface HashProp {
  foundHash: string
  storedHash: string
}

export const HashMismatchError = ModernError.subclass('HashMismatchError', {
  props: {} as HashProp,
  custom: class extends ModernError {
    constructor(
      message: string,
      options?: InstanceOptions & { props?: Partial<HashProp> },
    ) {
      super(message, options)

      if (typeof options?.props?.foundHash !== 'string') {
        throw new TypeError('Please specify a "foundHash" option.')
      }
    }
  },
})

  1. If you want to do anything more complex, such as transforming the value of those props, you should then use custom constructor options, as shown in my example above.

This would result in something like this. Please note those are passed as part of the error constructor's second argument, which is an object. This is slightly different from your example, which used two positional arguments instead. Modern-errors' constructors always take the following shape: new Error('message', options) because this is the standard JavaScript's shape. Changing that shape would also conflict with some built-in features: for example, this would prevent users from being able to pass options.props.

So when adding custom options, one can either add them to the constructor's second argument (which is an object), or add a third positional argument. Either way, those must be optional.

new HashMismatchError(`Expected x but got y`, { foundHash, storedHash })

Please let me know what you think! :)

Outstanding. Thank you sir. You have solved all of my problems.

  1. I was unaware of the InstanceType utility which was a big missing piece.
  2. Both of those examples involving default and validated props are excellent and would improve the documented examples if they're not already there.

Thanks for your awesome library and help. Cheers sir.

Good suggestion! I have just improved the following documentation based on our discussion:

Please let me know what you think!

@all-contributors Could you please add @renzor-fist for question and ideas?