Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

check with explicit type signature gives error on is.object.matching with a partial object #17

Open
SimoneGianni opened this issue Aug 19, 2022 · 4 comments
Assignees

Comments

@SimoneGianni
Copy link
Owner

No description provided.

@SimoneGianni SimoneGianni self-assigned this Aug 19, 2022
@SimoneGianni
Copy link
Owner Author

The problem is rather complex and involves how types are inferred in typescript. I've been fighting with this quite a lot so I'll write here my current findings so that I can refer to them later when I'll fight against this again.

The final goal would be something like this:

let obj :DaInterface = {
  a:1
  b:2,
  c:3,
  d:4
};

check(obj, is.object.matching({
  a: 1,  // This is a number so it obviously matches
  b: is.number, // This is a matcher to a number, 
  c: is.string, // This should give compile time error, in no case c can be a string
  //d is missing and it's ok, we don't want to check it
  xx :is.string, // This should give compile time error, in no case xx is allowed to exist
  zz: is.undefined, //  This should NOT give error, we want to explicitly check that something is not set
});

So, the requirements are:

  1. The content of object.matching is type checked
  2. It is type checked based on the type of the first operand, obj in this example
  3. Keys must exist
  4. Values must be of correct type
  5. Values can be of a matcher for the correct type (or matcher for any, as in is.defined)
  6. Not all the keys must be specified
  7. Non existing keys are allowed as long as of type "undefined" (or matcher undefined)
  8. Ann al the requirements above must work recursively on sub-objects

Let's start from how check is defined:

check<T>(value :T, matcher :Matcher<T>);

Given that, a naive implementation of objectMatching could be:

objectMatching<T>(sample :T):Matcher<T>

This immediately resolves requirements 1,2,3 and 4 above.

We can support also requirement 5 by creating an "unwrapping" type, that unwraps the matchers into the corresponding base type, which is currently implemented as OrigObj and I'll ignore it in the following discussion.

Other requirements are however mostly broken.

Let's start with requirement 6, that not all keys must be specified, for in check to be the same as in objectMatching, if one key is missing compiler will complain:

check<DaInterface>(value :DaInterface, objectMatching<DaInterface>(sample :DaInterface))

However, the case above is not what usually happens (sometimes yer, sometimes no), what happens is slightly different.

Typescript does not propagate the type from the check call into the objectMatching call, rather objectMatching resolves to it's own type, which is the eventually compatible.

For example:

check(obj, objectMatching({a:1}));

Is interpreted as:

check<DaInterface>(value :DaInterface, matcher: Matcher<{a:number}>)

So, since {a:number} cannot be casted to DaInterface, cause it's missing b,c etc... which are required in DaInterface, it will give error, so we need to specify a type that is compatible with DaInterface.

(and in recent typescript, it will interpret it as Matcher<{a:1}> which makes things slightly more complex in some scenarios like when enums are used)

Curiously, also requirement 7 is broken, cause unknown keys can be freely specified:

check(obj, objectMatching({a:1, b:1, c:1 .... ,notthere: 5}));
check<DaInterface>(value :DaInterface, matcher: Matcher<{a:number, b:number, ... , notthere: number}>)

The matcher type argument can be casted into DaInterface, cause it has a,b,c etc.. and the fact that is also has notthere:number still makes it a viable cast to DaInterface.

To work around these two problems, I implemented a type called ObjMatch that is sort of a partial, and allows for other keys as long as they are Matcher.

Since typescript type parameters are all bivariant in strange ways, Matcher is a valid Matcher, so any key can be specified with any value, so de-facto requirement 7 is not there.

But at least requirement 6 is, sort-of.

It is satisfied when the Matcher returned is of the right type, otherwise no matter what we do with the signature of the parameter of objectMatching, the returned value will still me something like Matcher<{a:number}> which is not assignable to Matcher<DaInterface> cause b is missing.

Notice that when not using interfaces, the situations changes even more. As briefly mentioned above, types that typescript is creating automatically out of object literals are stricter than those declared using interfaces and types.

So, as of today, there is no way to obtain all the above requirements.

let obj :DaInterface = //...

check(obj, objectMatching({a:1})); // Works
check(obj, objectMatching({a:1,b:1})); // Works
check(obj, objectMatching({a:1,z:1})); // Gives error, looks like requirement 7 but it's not, because ...
check(obj, objectMatching({a:1,b:1,c:1,z:1})); // Works .. cause even if the Matcher type contains a `z` that should not there, all the rest is ok, so heyokey it works

It essentially satisfies requirement 6 (you can skip some properties) and 7 (no extraneous ones), until you don't check all the properties, in which case 6 is useless and 7 breaks for whatever reason.

Notice that in the example above also intellisense in VSCode works correctly.

However, in the wild, it does not work so smoothly. In more complex cases, the above syntax does not have the same properties. It usually complains about missing properties, while in the examples above the type being simpler and the properties being explicitly missing .. it still compiles properly.

Instead using:

check(obj, objectMatching<DaInterface>({a:1});

It at the moment the best option. It forces the Matcher type, and for some reason this "early cast" works most of the times.

It even works with:

check(obj, is.object.matching<typeof obj>({a:1}));

Which means the problem is somehow in the covariant/cotravariant nature of typescript type parameters.

This means that it could be fixed once microsoft/TypeScript#48240 gets into the language correctly.

@SimoneGianni
Copy link
Owner Author

The reason it works is that it propagates the type back!

let obj :DaInterface = //...
let matcher = is.object.matching({a:1});
check(obj, matcher);

It resolves to:

matcher :Matcher<OrigObj<{a:1}>>;
check<DaInterface>(value :DaInterface, matcher :Matcher<DaInterface>);

So, it then boils down on how much {a:1} is compatible with DaInterface.

INSTEAD

let obj :DaInterface = //...
check(obj, is.object.matching<DaInterface>({a:1}));

resolves to:

check<OrigObj<DaInterface>>(val :OrigObj<DaInterface>, matcher: Matcher<OrigObj<DaInterface>>);

So, instead of enforcing a cast "from check to the argument", it reinterprets the check signature in a way that works.

And in this case it works correctly because:

  1. The original value T is alway also a valid OrigObj, given OrigObj is in best case a simple partial.
  2. The problem then boils down to only matching the given object literal {a:1} with OrigObj, which works cause OrigObj is a partial (respects requirement 6) and should allow unknown keys only with Matcher or Matcher (which does not work cause of covariance of any to anything)
  3. The check call itself is always correct, cause both values are on the same type OrigObj

So, to keep most of the requirements without having to specify the type explicitly, we need to find a way to invert the resolution of the type parameters, if it's possible, forcing the objectMatching to accept the "incoming" type parameter from the outer check call.

@SimoneGianni
Copy link
Owner Author

The check(...).is(...) syntax works as expected in these cases.

The reason being that while in the single check<T>(val :T, matcher: Matcher<T>) it needs to guess a T that satisfies everybody, in the check<T>(val :T) .. .is(matcher :Matcher<T>) when we arrive to the .is part <T> is already defined and cannot be mutated.

@SimoneGianni
Copy link
Owner Author

I've extended the use of RecursivePartial, now the situation is:

check(obj, is.object.matching({...});

Does not give error for missing elements (before it did), so it satisfies requirement 6. However intellisense does not work, cause it changes everything to check<OrigObj<DaInterface>>(val :OrigObj<DaInterface>, matcher: Matcher<OrigObj<DaInterface>>); which are dynamic interfaces to there is no intellisense.

check(obj).is(objectMatching({...});

Works correctly, has intellisense, but allows for everything to be written in, cause OrigObj<{a:1,zzzzz:"ciao"}> is a perfect Matcher<{a:number}>.

check(obj, is.object.matching<DaInterface>({...}));
check(obj).is(objectMatching<DaInterface>({...}));

Is still the best option, cause it has intellisense, allows partials (requirement 6), gives errors on unknown (requirement 7), except that it allows unknown with matchers cause Matcher<any> is good for everything.

SimoneGianni added a commit that referenced this issue Aug 20, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant