Skip to content

Commit e4c78c7

Browse files
Make expectType assertion strict
1 parent b13533b commit e4c78c7

File tree

18 files changed

+326
-145
lines changed

18 files changed

+326
-145
lines changed

libraries/typescript/lib/typescript.d.ts

+4
Original file line numberDiff line numberDiff line change
@@ -1995,6 +1995,10 @@ declare namespace ts {
19951995
getAugmentedPropertiesOfType(type: Type): Symbol[];
19961996
getRootSymbols(symbol: Symbol): ReadonlyArray<Symbol>;
19971997
getContextualType(node: Expression): Type | undefined;
1998+
/**
1999+
* Checks if type `a` is assignable to type `b`.
2000+
*/
2001+
isAssignableTo(a: Type, b: Type): boolean;
19982002
/**
19992003
* returns unknownSignature in the case of an error.
20002004
* returns undefined if the node is not valid.

libraries/typescript/lib/typescript.js

+1
Original file line numberDiff line numberDiff line change
@@ -32344,6 +32344,7 @@ var ts;
3234432344
var parsed = ts.getParseTreeNode(node, ts.isFunctionLike);
3234532345
return parsed ? isImplementationOfOverload(parsed) : undefined;
3234632346
},
32347+
isAssignableTo: isTypeAssignableTo,
3234732348
getImmediateAliasedSymbol: getImmediateAliasedSymbol,
3234832349
getAliasedSymbol: resolveAlias,
3234932350
getEmitResolver: getEmitResolver,

media/screenshot.png

68.6 KB
Loading

media/strict-assert.png

55.4 KB
Loading

package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@
2727
"dist/index.js",
2828
"dist/index.d.ts",
2929
"dist/cli.js",
30-
"dist/lib"
30+
"dist/lib",
31+
"libraries"
3132
],
3233
"keywords": [
3334
"typescript",
@@ -54,6 +55,7 @@
5455
"cpy-cli": "^2.0.0",
5556
"del-cli": "^1.1.0",
5657
"react": "^16.9.0",
58+
"rxjs": "^6.5.3",
5759
"tslint": "^5.11.0",
5860
"tslint-xo": "^0.9.0",
5961
"typescript": "^3.6.3"

readme.md

+17-1
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,23 @@ export default concat;
5959

6060
If we don't change the test file and we run the `tsd` command again, the test will fail.
6161

62-
<img src="screenshot.png" width="1330">
62+
<img src="media/screenshot.png" width="1330">
63+
64+
### Strict type assertions
65+
66+
Type assertions are strict. This means that if you expect the type to be `string | number` but the argument is of type `string`, the tests will fail.
67+
68+
```ts
69+
import {expectType} from 'tsd';
70+
import concat from '.';
71+
72+
expectType<string>(concat('foo', 'bar'));
73+
expectType<string | number>(concat('foo', 'bar'));
74+
```
75+
76+
If we run `tsd`, we will notice that it reports an error because the `concat` method returns the type `string` and not `string | number`.
77+
78+
<img src="media/strict-assert.png" width="1330">
6379

6480
### Top-level `await`
6581

screenshot.png

-90 KB
Binary file not shown.

source/lib/compiler.ts

+104-24
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,24 @@ import * as path from 'path';
22
import {
33
flattenDiagnosticMessageText,
44
createProgram,
5-
SyntaxKind,
65
Diagnostic as TSDiagnostic,
76
Program,
87
SourceFile,
98
Node,
10-
forEachChild
9+
forEachChild,
10+
isCallExpression,
11+
Identifier,
12+
TypeChecker,
13+
CallExpression
1114
} from '../../libraries/typescript';
1215
import {Diagnostic, DiagnosticCode, Context, Location} from './interfaces';
1316

14-
// List of diagnostic codes that should be ignored
17+
// List of diagnostic codes that should be ignored in general
1518
const ignoredDiagnostics = new Set<number>([
1619
DiagnosticCode.AwaitIsOnlyAllowedInAsyncFunction
1720
]);
1821

22+
// List of diagnostic codes which should be ignored inside `expectError` statements
1923
const diagnosticCodesToIgnore = new Set<DiagnosticCode>([
2024
DiagnosticCode.ArgumentTypeIsNotAssignableToParameterType,
2125
DiagnosticCode.PropertyDoesNotExistOnType,
@@ -27,30 +31,23 @@ const diagnosticCodesToIgnore = new Set<DiagnosticCode>([
2731
]);
2832

2933
/**
30-
* Extract all the `expectError` statements and convert it to a range map.
34+
* Extract all assertions.
3135
*
32-
* @param program - The TypeScript program.
36+
* @param program - TypeScript program.
3337
*/
34-
const extractExpectErrorRanges = (program: Program) => {
35-
const expectedErrors = new Map<Location, Pick<Diagnostic, 'fileName' | 'line' | 'column'>>();
38+
const extractAssertions = (program: Program) => {
39+
const typeAssertions = new Set<CallExpression>();
40+
const errorAssertions = new Set<CallExpression>();
3641

3742
function walkNodes(node: Node) {
38-
if (node.kind === SyntaxKind.ExpressionStatement && node.getText().startsWith('expectError')) {
39-
const location = {
40-
fileName: node.getSourceFile().fileName,
41-
start: node.getStart(),
42-
end: node.getEnd()
43-
};
44-
45-
const pos = node
46-
.getSourceFile()
47-
.getLineAndCharacterOfPosition(node.getStart());
48-
49-
expectedErrors.set(location, {
50-
fileName: location.fileName,
51-
line: pos.line + 1,
52-
column: pos.character
53-
});
43+
if (isCallExpression(node)) {
44+
const text = (node.expression as Identifier).getText();
45+
46+
if (text === 'expectType') {
47+
typeAssertions.add(node);
48+
} else if (text === 'expectError') {
49+
errorAssertions.add(node);
50+
}
5451
}
5552

5653
forEachChild(node, walkNodes);
@@ -60,9 +57,88 @@ const extractExpectErrorRanges = (program: Program) => {
6057
walkNodes(sourceFile);
6158
}
6259

60+
return {
61+
typeAssertions,
62+
errorAssertions
63+
};
64+
};
65+
66+
/**
67+
* Loop over all the `expectError` nodes and convert them to a range map.
68+
*
69+
* @param nodes - Set of `expectError` nodes.
70+
*/
71+
const extractExpectErrorRanges = (nodes: Set<Node>) => {
72+
const expectedErrors = new Map<Location, Pick<Diagnostic, 'fileName' | 'line' | 'column'>>();
73+
74+
// Iterate over the nodes and add the node range to the map
75+
for (const node of nodes) {
76+
const location = {
77+
fileName: node.getSourceFile().fileName,
78+
start: node.getStart(),
79+
end: node.getEnd()
80+
};
81+
82+
const pos = node
83+
.getSourceFile()
84+
.getLineAndCharacterOfPosition(node.getStart());
85+
86+
expectedErrors.set(location, {
87+
fileName: location.fileName,
88+
line: pos.line + 1,
89+
column: pos.character
90+
});
91+
}
92+
6393
return expectedErrors;
6494
};
6595

96+
/**
97+
* Assert the expected type from `expectType` calls with the provided type in the argument.
98+
* Returns a list of custom diagnostics.
99+
*
100+
* @param checker - The TypeScript type checker.
101+
* @param nodes - The `expectType` AST nodes.
102+
* @return List of custom diagnostics.
103+
*/
104+
const assertTypes = (checker: TypeChecker, nodes: Set<CallExpression>): Diagnostic[] => {
105+
const diagnostics: Diagnostic[] = [];
106+
107+
for (const node of nodes) {
108+
if (!node.typeArguments) {
109+
// Skip if the node does not have generics
110+
continue;
111+
}
112+
113+
// Retrieve the type to be expected. This is the type inside the generic.
114+
const expectedType = checker.getTypeFromTypeNode(node.typeArguments[0]);
115+
const argumentType = checker.getTypeAtLocation(node.arguments[0]);
116+
117+
if (!checker.isAssignableTo(argumentType, expectedType)) {
118+
// The argument type is not assignable to the expected type. TypeScript will catch this for us.
119+
continue;
120+
}
121+
122+
if (!checker.isAssignableTo(expectedType, argumentType)) { // tslint:disable-line:early-exit
123+
/**
124+
* At this point, the expected type is not assignable to the argument type, but the argument type is
125+
* assignable to the expected type. This means our type is too wide.
126+
*/
127+
const position = node.getSourceFile().getLineAndCharacterOfPosition(node.getStart());
128+
129+
diagnostics.push({
130+
fileName: node.getSourceFile().fileName,
131+
message: `Parameter type \`${checker.typeToString(expectedType)}\` is declared too wide for argument type \`${checker.typeToString(argumentType)}\`.`,
132+
severity: 'error',
133+
line: position.line + 1,
134+
column: position.character,
135+
});
136+
}
137+
}
138+
139+
return diagnostics;
140+
};
141+
66142
/**
67143
* Check if the provided diagnostic should be ignored.
68144
*
@@ -112,7 +188,11 @@ export const getDiagnostics = (context: Context): Diagnostic[] => {
112188
.getSemanticDiagnostics()
113189
.concat(program.getSyntacticDiagnostics());
114190

115-
const expectedErrors = extractExpectErrorRanges(program);
191+
const {typeAssertions, errorAssertions} = extractAssertions(program);
192+
193+
const expectedErrors = extractExpectErrorRanges(errorAssertions);
194+
195+
result.push(...assertTypes(program.getTypeChecker(), typeAssertions));
116196

117197
for (const diagnostic of diagnostics) {
118198
if (!diagnostic.file || ignoreDiagnostic(diagnostic, expectedErrors)) {

source/test/fixtures/missing-import/index.test-d.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@ import {LiteralUnion} from '.';
33

44
type Pet = LiteralUnion<'dog' | 'cat', string>;
55

6-
expectType<Pet>('dog');
7-
expectType<Pet>('cat');
8-
expectType<Pet>('unicorn');
6+
expectType<Pet>('dog' as Pet);
7+
expectType<Pet>('cat' as Pet);
8+
expectType<Pet>('unicorn' as Pet);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
declare const one: {
2+
(foo: string, bar: string): string;
3+
(foo: number, bar: number): number;
4+
<T>(): T;
5+
};
6+
7+
export default one;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module.exports.default = (foo, bar) => {
2+
return foo + bar;
3+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import {Observable} from 'rxjs';
2+
import {expectType} from '../../../..';
3+
import one from '.';
4+
5+
expectType<string>('cat');
6+
7+
expectType<string | number>(one('foo', 'bar'));
8+
expectType<string | number>(one(1, 2));
9+
10+
expectType<Date | string>(new Date('foo'));
11+
expectType<Promise<number | string>>(new Promise<number>(resolve => resolve(1)));
12+
expectType<Promise<number | string> | string>(new Promise<number | string>(resolve => resolve(1)));
13+
14+
expectType<Promise<string | number>>(Promise.resolve(1));
15+
16+
expectType<Observable<string | number>>(
17+
one<Observable<string>>()
18+
);
19+
20+
expectType<Observable<string | number> | Observable<string | number | boolean>>(
21+
one<Observable<string | number> | Observable<string>>()
22+
);
23+
24+
abstract class Foo<T> {
25+
abstract unicorn(): T;
26+
}
27+
28+
expectType<Foo<string | Foo<string | number>> | Foo<Date> | Foo<Symbol>>(
29+
one<Foo<Date> | Foo<Symbol> | Foo<Foo<number> | string>>()
30+
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"name": "foo",
3+
"dependencies": {
4+
"rxjs": "^6.5.3"
5+
}
6+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
declare const one: {
2+
(foo: string, bar: string): string;
3+
(foo: number, bar: number): number;
4+
<T>(): T;
5+
};
6+
7+
export default one;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module.exports.default = (foo, bar) => {
2+
return foo + bar;
3+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import {Observable} from 'rxjs';
2+
import {expectType} from '../../../..';
3+
import one from '.';
4+
5+
abstract class Foo<T> {
6+
abstract unicorn(): T;
7+
}
8+
9+
expectType<string>(one('foo', 'bar'));
10+
expectType<number>(one(1, 2));
11+
12+
expectType<Date>(new Date('foo'));
13+
expectType<Promise<number>>(new Promise<number>(resolve => resolve(1)));
14+
expectType<Promise<number | string>>(new Promise<number | string>(resolve => resolve(1)));
15+
16+
expectType<Promise<number>>(Promise.resolve(1));
17+
18+
expectType<Observable<string>>(one<Observable<string>>());
19+
20+
expectType<Observable<string | number> | Observable<Date> | Observable<Symbol>>(
21+
one<Observable<Date> | Observable<Symbol> | Observable<number | string>>()
22+
);
23+
24+
expectType<Foo<string | Foo<string | number>> | Foo<Date> | Foo<Symbol>>(
25+
one<Foo<Date> | Foo<Symbol> | Foo<Foo<number | string> | string>>()
26+
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"name": "foo",
3+
"dependencies": {
4+
"rxjs": "^6.5.3"
5+
}
6+
}

0 commit comments

Comments
 (0)