hoeck / simple-runtypes

Small, efficient and extendable runtype library for Typescript
MIT License
114 stars 5 forks source link

Performance and throwing errors #11

Closed pongo closed 3 years ago

pongo commented 3 years ago

Hello. You've created an good library, but there is a performance problem.

Look at this benchmark. We validate two cases: correct data and incorrect data. io-ts in both cases has approximately the same performance. But simple-runtypes has a significant performance decrease on incorrect data.

In my opinion, this is because you are throwing errors. The standard error should collect a stacktrace — it's slow. Throwing is also slow.


What can we do?

1. Throw away Error and use stackless error objects

import { inherits } from 'util';

export class RuntypeError {
  readonly name: string
  readonly path?: (string | number)[]
  readonly value?: any

  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
  constructor(message: string, value?: any, path?: (string | number)[]) {
    this.name = 'RuntypeError'
    this.path = path
    this.value = value
  }
}

inherits(RuntypeError, Error); // new RuntypeError() instanceof Error === true

2. Stop throwing errors

You can, for example, return a ValidationResult:

type ValidationResult<T> = 
  | { ok: true; value: T }
  | { ok: false; error: RuntypeError };
hoeck commented 3 years ago

Hey, thanks :heart: for benchmarking and reporting this.

Using exceptions was actually one my design goals as I like (and kind of depend) on its easy composability within my other projects. I'm already wrapping things internally to keep things fast and did not really optimize for the failing part :D to be honest.

I did not know though that there is a way to make Error creation and throwing faster. Have to check what utils.inherit does as I need this library to work in browsers too.

Or I might as well publish and document the internal interface that returns a Fail object instead of throwing an Error.

I'll go and use your benchmark to check your suggestions.

pongo commented 3 years ago

If you can use custom errors (without stacktrace) and add option "return errors instead throw" — it would be great. Or may be option "ignore errors" (just return false).

hoeck commented 3 years ago

Oh yeah, let me try both - an additional .check method similar to how other runtype libs do it and a (maybe as a configurable option bc. I don't need the fail-efficiency in the frontend) stackless efficient error object. It will take some days though bc. I've got a deadline at work :cry:

hoeck commented 3 years ago

@pongo had a look into this:

I think stackless errors are too surprising for a user and I (in front and backend code) rely heavily on stacktraces to find where bad data came from. Its already hard enough with async code - not having traces at all might be too frustrating.

Benchmarking without try/catch and throwing errors results in a tremendous speedup just as expected.

But now I need your input:

Would you prefer a global check<T>(Runtype<T>, v: unknown): ValidationResult<T> function or rather that function as a method on all runtypes so it can be easily chained and discovered?

Chaining is more discoverable via autocomplete but IMO harder to implement as I'd probably need to have some kind of base class that implements .check(v :unkown): ValidationResult<T> and passes it to runtypes via prototype to reduce overhead.

On the other hand, I'm not using chaining at all in this library so one would actually expect a st.check() function because that's where all the other functions are. And its simpler to implement.

Anyway, I'm starting with a check() function for now.

hoeck commented 3 years ago

Added the option to use a runtype and let it return a validation result instead of throwing an error: st.use(thatRuntype, unknownValue): https://github.com/hoeck/simple-runtypes/blob/907f687907fe489d23170519096a5b5c54d04d11/src/custom.ts#L51