Closed alexmojaki closed 3 years ago
I think the API needs some redesigning. The names test
, check
, and validate
are easy to confuse, there's a strict
copy of each one, and it's hard to add more options without creating a combinatorial explosion of methods. In particular I want to add options for https://github.com/gristlabs/ts-interface-checker/pull/4#issuecomment-808807995 and https://github.com/alexmojaki/ts-interface-checker/pull/1, and there may be more in the future such as an option to deal with #46 or #38.
I propose having one central method which users are encouraged to use where they can configure everything, something like this:
export class Checker {
// This is the central method, but we could create a new method with a new name instead
public validate(value: any, options: CheckOptions): IErrorDetail | null {
...
}
public check(value: any, options: CheckOptions): void {
this.validate(value, {...options, onError: OnError.throw});
}
public test(value: any, options: CheckOptions): boolean {
return this.validate(
value,
{...options, onError: OnError.return, numErrors: 0}
) == null;
}
...
}
interface CheckOptions {
extras: Extras
onError: OnError
/**
* Number of errors that can be nested directly
* under another error.
*
* Internally, the number of forks a DetailContext can hold.
*
* 0: Don't record anything.
* - Used for test() to just return a boolean
* - Could be used in `check()` if someone wants to raise an error
* but doesn't want a message.
* - Creates a NoopContext
* 1: Old default behaviour for check/validate
* > 1: Can nest several errors
* - May lead to a multiline error message when using OnError.throw
* - Requires checkRoot to be true
*/
numErrors: number
/**
* If true, wraps the top level error in another layer about the
* root type being checked. For example, this would change
* the error message from:
*
* value.extras is missing
*
* to:
*
* value is not a CheckOptions; value.extras is missing
*
* Similarly the returned IErrorDetail will have an extra top layer.
*
* This is required if numErrors > 1 so that multiple errors can be
* nested under the root IErrorDetail.
*
* Internally this wraps the type being checked in a TName first.
*/
checkRoot: boolean
}
/** What to do when extraneous values are found */
enum Extras {
/** Allow extra values */
ignore,
/** Formerly strict mode: do what onError says */
error,
/** Remove extra values in place */
delete,
}
/** What to do when an error is encountered */
enum OnError {
/** Throw a VError */
throw,
/** Return an object describing the errors */
return,
}
Questions:
numErrors
to 3? Require specifying certain options?strict
methods, but leaving check
and test
as is for the clean return types.numErrors: 1, checkRoot: true
, or check(value, {onError: OnError.throw})
? Just overwrite them, or throw an error?Agreed that an
options
object is more flexible.I like adding an option for
extraProps
to make "strict" versions redundant, and to add the option to delete extra properties. So perhaps all methods can takeoptions
with{extraProps?: 'error' | 'delete'}
, by default omitted.
So if I understand correctly:
options
will only have one key extraProps
and none of the others mentioned, so its purpose (as opposed to an extraProps
argument) will be to make adding more options in the future easier.options
itself and extraProps
are optional.By the way, I called it extras
because in general it also deals with extra items in tuples and parameter lists and I didn't know if those are considered 'properties', but I'm happy with extraProps
since interface properties are what people usually have in mind anyway.
For the distinction between validate / check / test, I think using separate methods is a bit better than a single method with different return types -- in particular for type-checking, and explaining what it does. As long as these methods exist, I don't see much purpose in emulating them by passing options to some unified method.
Last but not least, for the level of details in errors, I think more informative errors are just better. The only reason against making them the only behavior is if there is existing code that relies on the previous format. I am thinking, if we bump the major version and explain the difference, it would be fine to just upgrade the errors to contain more details, and avoid burdening the user with more options.
Right, the idea of a central method with lots of options was that the user would have one clear place to look to discover all their needs, but if we just reduce the number of options there's less need for that and I like your thinking.
So shall the number of errors per object just be fixed at 3?
Agreed that an
options
object is more flexible. I like adding an option forextraProps
to make "strict" versions redundant, and to add the option to delete extra properties. So perhaps all methods can takeoptions
with{extraProps?: 'error' | 'delete'}
, by default omitted.So if I understand correctly:
1. `options` will only have one key `extraProps` and none of the others mentioned, so its purpose (as opposed to an `extraProps` argument) will be to make adding more options in the future easier. 2. Both `options` itself and `extraProps` are optional.
Right. It reduces the number of methods to keep in mind in half, and alos allows adding the "delete" interface easily.
By the way, I called it
extras
because in general it also deals with extra items in tuples and parameter lists and I didn't know if those are considered 'properties', but I'm happy withextraProps
since interface properties are what people usually have in mind anyway.
Yeah. Just extras
made me think these options might be for extra functionality or extra checks, and wander what it might mean. I think extraProps
sounds reasonable for tuples too. Or maybe extraneous
if you like that better.
For the distinction between validate / check / test, I think using separate methods is a bit better than a single method with different return types -- in particular for type-checking, and explaining what it does. As long as these methods exist, I don't see much purpose in emulating them by passing options to some unified method. Last but not least, for the level of details in errors, I think more informative errors are just better. The only reason against making them the only behavior is if there is existing code that relies on the previous format. I am thinking, if we bump the major version and explain the difference, it would be fine to just upgrade the errors to contain more details, and avoid burdening the user with more options.
Right, the idea of a central method with lots of options was that the user would have one clear place to look to discover all their needs, but if we just reduce the number of options there's less need for that and I like your thinking.
So shall the number of errors per object just be fixed at 3?
I think it's a good choice. I don't really see myself changing it, but if anyone cares about it, it could be added as an option.
Now it's ready!
Some notes:
validate() now returns an array. My previous idea of adding an extra check at the root by checking a TName doesn't work if you want to use a Checker returned by the methods such as getProp where there might not be a name.
I've made the error message as flat as possible to emulate the old behaviour and reduce newlines. For example in the new tests you can see this error:
value.ab is not a AB value.ab is not a A; value.ab.a is missing value.ab is not a B; value.ab.b is missing
If error messages were consistently tree-like, it would look like this:
value.ab is not a AB value.ab is not a A value.ab.a is missing value.ab is not a B; value.ab.b is missing
The comments on errorLines
explain in more detail.
What should happen once you're happy with this? https://github.com/alexmojaki/ts-interface-checker/pull/1 comes next but both PRs introduce breaking changes. Leave this unmerged until https://github.com/alexmojaki/ts-interface-checker/pull/1 is merge ready? Merge this into master but don't release?
I've done a detailed benchmark analysis. Seems like it's now 16% slower.
Disclaimer: I'm not a statistician.
This is an attempt to solve #3 and #16, allowing the result of
validate
to have multiple errors in the samenested
array so that users can solve several problems in their input data at once.At the moment this is a proof of concept, there's still quite a bit of work to do and decisions to be made, including whether to go forward with this at all.
Most types don't need to be able to report multiple errors so they haven't changed. Some types we could consider adding this to but I'm reluctant about:
So for now it's just TIface and TIntersection. TIface is a good example to show how this works.
First, this line is unchanged:
If it's not an object, there's nothing else to report, so we call
ctx.fail
directly and then return early. But from here on,fail
must no longer be called directly onctx
, at least until this checker returns. Failures will instead be stored in forks. These could be renamed to branches, children, subcontexts, etc. They should be used as follows:ctx.fork()
ctx
for (potential) failures, whether it's in a deeper checker as inpropCheckers[i](v, fork)
or directly as infork.fail(name, null, 1)
.return !ctx.failed()
to check whether any failures were gathered along the way without triggering an early return.The NoopContext still does very little, only storing a boolean and returning itself from
fork()
. But the extra operations have some inevitable overhead. I haven't done proper calculations yet but at a glance the benchmark (great thing to have!) seems to run something like 10-15% slower.The DetailContext keeps forks that have failures in them. The way it does this right now is not very clever and will probably be optimised. The number of failed forks it keeps is limited (right now to 3, but that will be made configurable) so that users don't get flooded with errors. Once it reaches that limit,
ctx.completeFork()
returns false to indicate you should exit. But since you can now have a whole tree of contexts, the total number of forks and errors is unlimited.Some things that still need addressing:
What are your thoughts so far?