Closed agross closed 1 year ago
Firstly apologies for this, these types probably shouldn't have been released in that state, which is my fault. It's one of the last things I did as a maintainer -I bought them in as an experiment on the beta branch, and I forgot to highlight this with anyone before I stepped down. My failure to communicate this meant that the current maintainers (quite reasonably) didn't know about the potential problems with this type when 10.x was released, and I forgot all about it.
(As an aside, I can't reproduce this with Pact 10.4.1 and Typescript 4.9.4 - so changing the typescript version to match might fix it. However, the problem is in Pact, not in your code)
I've ended up writing a long answer - the tl;dr is:
As a workaround, don't use InterfaceToTemplate
and instead just spread the Foo
object when you pass it to like
- body: like({...f})
.
Note that the like
matcher cascades, so:
like({a: "working example"})
is the same as
like({a: like("working example")})
Read on for all the gory details.
The problem stems from trying to bring in a type to represent arbitrary JSON (the motivation was to avoid mistakes like people putting functions or Date
objects into their pact expectations). Unfortunately, this isn't possible in Typescript.
The problem is that typescript thinks (correctly) that the narrower interface
definition isn't arbitrarily indexable (that is, Foo
is explicitly not arbitrary json - if you have a Foo
, you can't index it with f['someOtherProp']
).
Why did I think forcing arbitrarily indexable objects was an acceptable restriction? Well, I reasoned that the type on the wire (what you're defining in the withRequest
section) isn't actually a Foo
, it's actually just JSON. Perhaps it later unmarshalls to a Foo
, but at the time you're defining the expectation for Pact, it's just JSON with the same structure as a Foo
. I thought these types would encourage people to be more explicit, and not put their business object types in the JSON expectations.
Unfortunately, this reasoning (although technically correct) is bad - even though the JSON body isn't technically a Foo
, it's an additional layer of safety in your test to enforce the compiler to complain if the structure of your JSON expectation doesn't match Foo
. This extra layer of safety is what you're doing in your example.
Your example is a good practice, and these misguided types are preventing you from doing it.
It will work if you use type
instead of interface
, but that's more of a hack that blinds typescript, rather than the right approach. I don't think Pact should require you to write your types a particular way. So, we want a workaround that doesn't require you to change your types.
Here are two workarounds that don't require you to change your type system. They also blind typescript to the problem, but still let you benefit from compile errors if your Pact expectation deviates from Foo
.
Object.freeze
to tell typescript that the object is immutable (this happens to have a similar effect to InterfaceToTemplate
- or at least, how InterfaceToTemplate
should have been written - see the next section for the details). I don't think it matters which you choose.
Here's both options:
interface Foo {
a: string;
}
const f: Foo = { a: "working example" };
provider.addInteraction({
uponReceiving: "a post with foo",
withRequest: {
method: "POST",
path: "/",
// Then either (1):
body: like(Object.freeze(f)),
// Or (2):
body: like({...f}),
...
Why doesn't InterfaceToTemplate<Foo>
work, when the documentation says it will? Well, the definition of that type in Pact is wrong, too. It is:
export type InterfaceToTemplate<O> = { [K in keyof O]: AnyTemplate };
But it should have been:
export type InterfaceToTemplate<O> = {
[K in keyof O]: InterfaceToTemplate<O[K]> | Matcher<O[K]>;
};
If these template types live on, this type should be corrected.
But really, the right fix is to remove all the AnyTemplate
, AnyJson
, InterfaceToTemplate
etc types in the next major version (and whether or not to expose explicit types for the matchers should be rethought).
These types were a mistake, and I'm very sorry.
@mefellows, let me know if you would like a PR that corrects this.
Hello @TimothyJones,
thank you very much for your detailed answer! There are a couple of follow-up questions that came up while trying your suggestions.
The interaction I want to specify should return an object that contains other objects. This seems to be the reason your suggestions do not work. On top of that, the interaction should return an array of such objects.
interface Foo {
a: string;
}
const f: Foo = { a: 'working example' };
Matchers.like({ ...f });
Matchers.like(Object.freeze(f));
Matchers.atLeastLike({ ...f }, 1);
Matchers.atLeastLike(Object.freeze(f), 1);
interface Room {
id: string,
foo: Foo
}
const r: Room = { id: 'some guid', foo: { a: "example" }};
Matchers.like({ ...r });
// Argument of type '{ id: string; foo: Foo; }' is not assignable to parameter of type 'AnyTemplate'.
// Type '{ id: string; foo: Foo; }' is not assignable to type 'null'.
Matchers.like(Object.freeze(r));
// Argument of type 'Readonly<Room>' is not assignable to parameter of type 'AnyTemplate'.
// Type 'Readonly<Room>' is not assignable to type 'TemplateMap'.
// Property 'foo' is incompatible with index signature.
// Type 'Foo' is not assignable to type 'string | number | boolean | JsonArray | JsonMap | TemplateMap | TemplateArray | Matcher<unknown> | null'.
// Type 'Foo' is not assignable to type 'TemplateMap'.
// Index signature for type 'string' is missing in type 'Foo'.
Matchers.atLeastLike({ ...r }, 1);
// Argument of type '{ id: string; foo: Foo; }' is not assignable to parameter of type 'AnyTemplate'.
// Type '{ id: string; foo: Foo; }' is not assignable to type 'null'.
Matchers.atLeastLike(Object.freeze(r), 1);
// Argument of type 'Readonly<Room>' is not assignable to parameter of type 'AnyTemplate'.
like
for specific members?Let's say I want to be specific about the format of a particular interface member. As far as I understand like
checks for the type, but the data format does not matter. Let's pretend Room.id
should be a UUID encoded as a string.
const r: Room = {
id: Matchers.uuid(),
// Type 'RegexMatcher' is not assignable to type 'string'.
foo: { a: "example" }
};
So in order to use this, Matchers.uuid()
needs to return something that looks like a string, but it is a RegexMatcher
. I'm not a TypeScript expert enough to know whether TypeScrript supports something like implicit conversions (C# does).
PS: I'm using the same TypeScript and Pact version as you.
Just found a suggestion on Stack Overflow: https://stackoverflow.com/a/67406755/149264
Adjusted to the current Pact version (where Matchers.MatcherResult
does not exist) this seems to fix the example above.
type WrappedPact<T> = T extends object
? {
[K in keyof T]: WrappedPact<T[K]>;
}
: T | Matchers.Matcher<T>;
interface Foo {
a: string;
}
const f: WrappedPact<Foo> = { a: 'working example' };
Matchers.like(f);
Matchers.atLeastLike(f, 1);
interface Room {
id: string;
foo: Foo;
}
const r: WrappedPact<Room> = { id: 'some guid', foo: { a: 'example' } };
Matchers.like(r);
Matchers.atLeastLike(r, 1);
Even nested specifications work!
const r: WrappedPact<Room> = { id: Matchers.uuid(), foo: { a: Matchers.regex(/some/, 'example') } };
I think something like this should be included in the distribution.
Thanks for the update and extra cases. I'll take a look at a PR to fix all this up first thing this week.
The nested example unfortunately is expected with that workaround - you have to spread or freeze each object , like Matchers.like({...r, foo: {...r.foo} })
.
It looks like WrappedPact
will solve the specific problems raised so far, but at a quick glance, I don't think it is a general solution. For example, arrays will pass T extends object
, and then would get their function properties mapped (although perhaps that won't matter in practice). I think it would still be a good starting point, though, so thanks very much for the tip.
I've raised a PR that fixes this by removing the restriction that V3 matchers must be passed AnyTemplate
(and deprecates AnyTemplate
). This approach is nice because it's not a breaking change.
@agross , @TimothyJones I have trouble here with MatchersV3.boolean
Given an interface like
interface Foo {
bar: boolean;
}
and the value such as {bar: true}
or {bar: false}
, when trying to use with spread operator and the Typescript type suggested above
const matchedValues: PactMatchable<Foo[]> = values.map(
(w) => {
return {
...w,
bar: Matchers.boolean(),
};
}
);
or with an example value bar: Matchers.boolean(w.bar),
I get a typescript error
Type '{ bar: Matchers.Matcher<boolean>; }' is not assignable to type '{ bar: boolean | Matcher<false> | Matcher<true>; }'.
Types of property 'bar' are incompatible.
Type 'Matcher<boolean>' is not assignable to type 'boolean | Matcher<false> | Matcher<true>'.
Type 'Matcher<boolean>' is not assignable to type 'Matcher<false>'.
Type 'boolean' is not assignable to type 'false'.
Why does Matcher<boolean>
is getting transformed to Matcher<false> | Matcher<true>
?
Where is the PactMatchable
type coming from? I don't think that's exported from Pact.
It's the same as WrappedPact
above.
Ah, I see! So, that type shouldn't be necessary after 11.x was released.
But, if you want to use it, you can correct the definition like so:
type PactMatchable<T> = [T] extends [object]
? {
[K in keyof T]: PactMatchable<T[K]>;
}
: T | Matcher<T>;
For the explanation, have a read of the Typescript documentation on distributive conditional types.
I'm not even certain that the corrected version works for all use cases - but it will at least work for the case in your comment.
@TimothyJones yes now in v11 everything works without that wrapper interface. Thank you!
Hello,
this is a question rather than a bug report or feature request.
I'm quite new to Pact and I would like to reuse the TypeScript interfaces my frontend app uses for the consumer-side of things.
The documentation about matching states that in order to use TypeScript interfaces inside e.g.
willRespondWith/body
you need to wrap them inInterfaceToTemplate
or usetype
instead of interface.I've tried both but the results where not quite satisfactory.
As far as I understand it would be beneficial to use existing
type
s andinterface
s to prevent typos and to define the correct expected value types for the mock server.On the other hand,
Matcher.<something>
seems incompatible with the types expected by interface members. The example from the docs only uses thelike
matcher around the interface, but not for its members.My question is how I would be able to achieve something like this with Pact:
The only thing that comes to mind is to slap
as unknown as string
after the matcher.Software versions
Pact JS 10.4.1
v16.19.0