Skip to content

Commit 508827c

Browse files
authored
fix(expect): make types better reflect reality (#11931)
1 parent 02df7d3 commit 508827c

File tree

6 files changed

+118
-44
lines changed

6 files changed

+118
-44
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Fixes
66

77
- `[expect]` Pass matcher context to asymmetric matchers ([#11926](https://github.com/facebook/jest/pull/11926) & [#11930](https://github.com/facebook/jest/pull/11930))
8+
- `[expect]` Improve TypeScript types ([#11931](https://github.com/facebook/jest/pull/11931))
89
- `[@jest/types]` Mark deprecated configuration options as `@deprecated` ([#11913](https://github.com/facebook/jest/pull/11913))
910
- `[jest-cli]` Improve `--help` printout by removing defunct `--browser` option ([#11914](https://github.com/facebook/jest/pull/11914))
1011
- `[jest-haste-map]` Use distinct cache paths for different values of `computeDependencies` ([#11916](https://github.com/facebook/jest/pull/11916))

packages/expect/src/asymmetricMatchers.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99
import * as matcherUtils from 'jest-matcher-utils';
1010
import {equals, fnNameFor, hasProperty, isA, isUndefined} from './jasmineUtils';
1111
import {getState} from './jestMatchersObject';
12-
import type {MatcherState} from './types';
12+
import type {
13+
AsymmetricMatcher as AsymmetricMatcherInterface,
14+
MatcherState,
15+
} from './types';
1316
import {iterableEquality, subsetEquality} from './utils';
1417

1518
const utils = Object.freeze({
@@ -18,22 +21,28 @@ const utils = Object.freeze({
1821
subsetEquality,
1922
});
2023

21-
export abstract class AsymmetricMatcher<T> {
24+
export abstract class AsymmetricMatcher<
25+
T,
26+
State extends MatcherState = MatcherState,
27+
> implements AsymmetricMatcherInterface
28+
{
2229
$$typeof = Symbol.for('jest.asymmetricMatcher');
2330

2431
constructor(protected sample: T, protected inverse = false) {}
2532

26-
protected getMatcherContext(): MatcherState {
33+
protected getMatcherContext(): State {
2734
return {
2835
...getState(),
2936
equals,
3037
isNot: this.inverse,
3138
utils,
32-
};
39+
} as State;
3340
}
3441

3542
abstract asymmetricMatch(other: unknown): boolean;
3643
abstract toString(): string;
44+
getExpectedType?(): string;
45+
toAsymmetricMatcher?(): string;
3746
}
3847

3948
class Any extends AsymmetricMatcher<any> {

packages/expect/src/index.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -354,8 +354,9 @@ const makeThrowingMatcher = (
354354
}
355355
};
356356

357-
expect.extend = (matchers: MatchersObject): void =>
358-
setMatchers(matchers, false, expect);
357+
expect.extend = <T extends JestMatcherState = JestMatcherState>(
358+
matchers: MatchersObject<T>,
359+
): void => setMatchers(matchers, false, expect);
359360

360361
expect.anything = anything;
361362
expect.any = any;
@@ -396,8 +397,10 @@ function assertions(expected: number) {
396397
Error.captureStackTrace(error, assertions);
397398
}
398399

399-
getState().expectedAssertionsNumber = expected;
400-
getState().expectedAssertionsNumberError = error;
400+
setState({
401+
expectedAssertionsNumber: expected,
402+
expectedAssertionsNumberError: error,
403+
});
401404
}
402405
function hasAssertions(...args: Array<any>) {
403406
const error = new Error();
@@ -406,8 +409,10 @@ function hasAssertions(...args: Array<any>) {
406409
}
407410

408411
matcherUtils.ensureNoExpected(args[0], '.hasAssertions');
409-
getState().isExpectingAssertions = true;
410-
getState().isExpectingAssertionsError = error;
412+
setState({
413+
isExpectingAssertions: true,
414+
isExpectingAssertionsError: error,
415+
});
411416
}
412417

413418
// add default jest matchers

packages/expect/src/jestMatchersObject.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,18 +37,21 @@ if (!global.hasOwnProperty(JEST_MATCHERS_OBJECT)) {
3737
});
3838
}
3939

40-
export const getState = (): MatcherState =>
40+
export const getState = <State extends MatcherState = MatcherState>(): State =>
4141
(global as any)[JEST_MATCHERS_OBJECT].state;
4242

43-
export const setState = (state: Partial<MatcherState>): void => {
43+
export const setState = <State extends MatcherState = MatcherState>(
44+
state: Partial<State>,
45+
): void => {
4446
Object.assign((global as any)[JEST_MATCHERS_OBJECT].state, state);
4547
};
4648

47-
export const getMatchers = (): MatchersObject =>
48-
(global as any)[JEST_MATCHERS_OBJECT].matchers;
49+
export const getMatchers = <
50+
State extends MatcherState = MatcherState,
51+
>(): MatchersObject<State> => (global as any)[JEST_MATCHERS_OBJECT].matchers;
4952

50-
export const setMatchers = (
51-
matchers: MatchersObject,
53+
export const setMatchers = <State extends MatcherState = MatcherState>(
54+
matchers: MatchersObject<State>,
5255
isInternal: boolean,
5356
expect: Expect,
5457
): void => {
@@ -61,8 +64,14 @@ export const setMatchers = (
6164
if (!isInternal) {
6265
// expect is defined
6366

64-
class CustomMatcher extends AsymmetricMatcher<[unknown, unknown]> {
65-
constructor(inverse: boolean = false, ...sample: [unknown, unknown]) {
67+
class CustomMatcher extends AsymmetricMatcher<
68+
[unknown, ...Array<unknown>],
69+
State
70+
> {
71+
constructor(
72+
inverse: boolean = false,
73+
...sample: [unknown, ...Array<unknown>]
74+
) {
6675
super(sample, inverse);
6776
}
6877

@@ -89,14 +98,14 @@ export const setMatchers = (
8998
}
9099
}
91100

92-
expect[key] = (...sample: [unknown, unknown]) =>
101+
expect[key] = (...sample: [unknown, ...Array<unknown>]) =>
93102
new CustomMatcher(false, ...sample);
94103
if (!expect.not) {
95104
throw new Error(
96105
'`expect.not` is not defined - please report this bug to https://github.com/facebook/jest',
97106
);
98107
}
99-
expect.not[key] = (...sample: [unknown, unknown]) =>
108+
expect.not[key] = (...sample: [unknown, ...Array<unknown>]) =>
100109
new CustomMatcher(true, ...sample);
101110
}
102111
});

packages/expect/src/types.ts

Lines changed: 40 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,8 @@ export type AsyncExpectationResult = Promise<SyncExpectationResult>;
2121

2222
export type ExpectationResult = SyncExpectationResult | AsyncExpectationResult;
2323

24-
export type RawMatcherFn = {
25-
(
26-
this: MatcherState,
27-
received: any,
28-
expected: any,
29-
options?: any,
30-
): ExpectationResult;
24+
export type RawMatcherFn<T extends MatcherState = MatcherState> = {
25+
(this: T, received: any, expected: any, options?: any): ExpectationResult;
3126
[INTERNAL_MATCHER_FLAG]?: boolean;
3227
};
3328

@@ -62,33 +57,54 @@ export type MatcherState = {
6257
};
6358
};
6459

65-
export type AsymmetricMatcher = Record<string, any>;
66-
export type MatchersObject = {[id: string]: RawMatcherFn};
60+
export interface AsymmetricMatcher {
61+
asymmetricMatch(other: unknown): boolean;
62+
toString(): string;
63+
getExpectedType?(): string;
64+
toAsymmetricMatcher?(): string;
65+
}
66+
export type MatchersObject<T extends MatcherState = MatcherState> = {
67+
[id: string]: RawMatcherFn<T>;
68+
};
6769
export type ExpectedAssertionsErrors = Array<{
6870
actual: string | number;
6971
error: Error;
7072
expected: string;
7173
}>;
72-
export type Expect = {
73-
<T = unknown>(actual: T): Matchers<T>;
74-
// TODO: this is added by test runners, not `expect` itself
75-
addSnapshotSerializer(arg0: any): void;
76-
assertions(arg0: number): void;
77-
extend(arg0: any): void;
78-
extractExpectedAssertionsErrors: () => ExpectedAssertionsErrors;
79-
getState(): MatcherState;
80-
hasAssertions(): void;
81-
setState(state: Partial<MatcherState>): void;
8274

83-
any(expectedObject: any): AsymmetricMatcher;
84-
anything(): AsymmetricMatcher;
75+
interface InverseAsymmetricMatchers {
8576
arrayContaining(sample: Array<unknown>): AsymmetricMatcher;
8677
objectContaining(sample: Record<string, unknown>): AsymmetricMatcher;
8778
stringContaining(expected: string): AsymmetricMatcher;
8879
stringMatching(expected: string | RegExp): AsymmetricMatcher;
89-
[id: string]: AsymmetricMatcher;
90-
not: {[id: string]: AsymmetricMatcher};
91-
};
80+
}
81+
82+
interface AsymmetricMatchers extends InverseAsymmetricMatchers {
83+
any(expectedObject: unknown): AsymmetricMatcher;
84+
anything(): AsymmetricMatcher;
85+
}
86+
87+
// Should use interface merging somehow
88+
interface ExtraAsymmetricMatchers {
89+
// at least one argument is needed - that's probably wrong. Should allow `expect.toBeDivisibleBy2()` like `expect.anything()`
90+
[id: string]: (...sample: [unknown, ...Array<unknown>]) => AsymmetricMatcher;
91+
}
92+
93+
export type Expect<State extends MatcherState = MatcherState> = {
94+
<T = unknown>(actual: T): Matchers<void>;
95+
// TODO: this is added by test runners, not `expect` itself
96+
addSnapshotSerializer(serializer: unknown): void;
97+
assertions(numberOfAssertions: number): void;
98+
// TODO: remove this `T extends` - should get from some interface merging
99+
extend<T extends MatcherState = State>(matchers: MatchersObject<T>): void;
100+
extractExpectedAssertionsErrors: () => ExpectedAssertionsErrors;
101+
getState(): State;
102+
hasAssertions(): void;
103+
setState(state: Partial<State>): void;
104+
} & AsymmetricMatchers &
105+
ExtraAsymmetricMatchers & {
106+
not: InverseAsymmetricMatchers & ExtraAsymmetricMatchers;
107+
};
92108

93109
interface Constructable {
94110
new (...args: Array<unknown>): unknown;

test-types/top-level-globals.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
beforeAll,
1515
beforeEach,
1616
describe,
17+
expect,
1718
test,
1819
} from '@jest/globals';
1920
import type {Global} from '@jest/types';
@@ -108,3 +109,36 @@ expectType<void>(describe.only.each(testTable)(testName, fn));
108109
expectType<void>(describe.only.each(testTable)(testName, fn, timeout));
109110
expectType<void>(describe.skip.each(testTable)(testName, fn));
110111
expectType<void>(describe.skip.each(testTable)(testName, fn, timeout));
112+
113+
/// expect
114+
115+
expectType<void>(expect(2).toBe(2));
116+
expectType<Promise<void>>(expect(2).resolves.toBe(2));
117+
118+
expectType<void>(expect('Hello').toEqual(expect.any(String)));
119+
120+
// this currently does not error due to `[id: string]` in ExtraAsymmetricMatchers - we should have nothing there and force people to use interface merging
121+
// expectError(expect('Hello').toEqual(expect.not.any(Number)));
122+
123+
expectType<void>(
124+
expect.extend({
125+
toBeDivisibleBy(actual: number, expected: number) {
126+
expectType<boolean>(this.isNot);
127+
128+
const pass = actual % expected === 0;
129+
const message = pass
130+
? () =>
131+
`expected ${this.utils.printReceived(
132+
actual,
133+
)} not to be divisible by ${expected}`
134+
: () =>
135+
`expected ${this.utils.printReceived(
136+
actual,
137+
)} to be divisible by ${expected}`;
138+
139+
return {message, pass};
140+
},
141+
}),
142+
);
143+
144+
// TODO: some way of calling `expect(4).toBeDivisbleBy(2)` and `expect.toBeDivisbleBy(2)`

0 commit comments

Comments
 (0)