Skip to content

Commit

Permalink
Add assignability expectations - fixes #39
Browse files Browse the repository at this point in the history
  • Loading branch information
SamVerschueren committed Nov 13, 2019
1 parent 206573b commit 3a375fa
Show file tree
Hide file tree
Showing 16 changed files with 179 additions and 34 deletions.
20 changes: 19 additions & 1 deletion readme.md
Expand Up @@ -77,6 +77,16 @@ If we run `tsd`, we will notice that it reports an error because the `concat` me

<img src="media/strict-assert.png" width="1330">

If you still want loose type assertion, you can use `expectAssignable` for that.

```ts
import {expectType, expectAssignable} from 'tsd';
import concat from '.';

expectType<string>(concat('foo', 'bar'));
expectAssignable<string | number>(concat('foo', 'bar'));
```

### Top-level `await`

If your method returns a `Promise`, you can use top-level `await` to resolve the value instead of wrapping it in an `async` [IIFE](https://developer.mozilla.org/en-US/docs/Glossary/IIFE).
Expand Down Expand Up @@ -142,7 +152,15 @@ These options will be overridden if a `tsconfig.json` file is found in your proj

### expectType&lt;T&gt;(value)

Check that `value` is identical to type `T`.
Check that the type of `value` is identical to type `T`.

### expectAssignable&lt;T&gt;(value)

Check that the type of `value` is assignable to type `T`.

### expectNotAssignable&lt;T&gt;(value)

Check that the type of `value` is not assignable to type `T`.

### expectError(function)

Expand Down
22 changes: 21 additions & 1 deletion source/lib/assertions/assert.ts
@@ -1,5 +1,5 @@
/**
* Check that `value` is identical to type `T`.
* Check that the type of `value` is identical to type `T`.
*
* @param value - Value that should be identical to type `T`.
*/
Expand All @@ -8,6 +8,26 @@ export const expectType = <T>(value: T) => { // tslint:disable-line:no-unused
// Do nothing, the TypeScript compiler handles this for us
};

/**
* Check that the type of `value` is assignable to type `T`.
*
* @param value - Value that should be assignable to type `T`.
*/
// @ts-ignore
export const expectAssignable = <T>(value: T) => { // tslint:disable-line:no-unused
// Do nothing, the TypeScript compiler handles this for us
};

/**
* Check that the type of `value` is not assignable to type `T`.
*
* @param value - Value that should not be assignable to type `T`.
*/
// @ts-ignore
export const expectNotAssignable = <T>(value: any) => { // tslint:disable-line:no-unused
// Do nothing, the TypeScript compiler handles this for us
};

/**
* Assert the value to throw an argument error.
*
Expand Down
39 changes: 39 additions & 0 deletions source/lib/assertions/handlers/assignability.ts
@@ -0,0 +1,39 @@
import {CallExpression} from '../../../../libraries/typescript/lib/typescript';
import {TypeChecker} from '../../entities/typescript';
import {Diagnostic} from '../../interfaces';
import {makeDiagnostic} from '../../utils';

/**
* Verifies that the argument of the assertion is not assignable to the generic type of the assertion.
*
* @param checker - The TypeScript type checker.
* @param nodes - The `expectType` AST nodes.
* @return List of custom diagnostics.
*/
export const isNotAssignable = (checker: TypeChecker, nodes: Set<CallExpression>): Diagnostic[] => {
const diagnostics: Diagnostic[] = [];

if (!nodes) {
return diagnostics;
}

for (const node of nodes) {
if (!node.typeArguments) {
// Skip if the node does not have generics
continue;
}

// Retrieve the type to be expected. This is the type inside the generic.
const expectedType = checker.getTypeFromTypeNode(node.typeArguments[0]);
const argumentType = checker.getTypeAtLocation(node.arguments[0]);

if (checker.isTypeAssignableTo(argumentType, expectedType)) {
/**
* The argument type is assignable to the expected type, we don't want this so add a diagnostic.
*/
diagnostics.push(makeDiagnostic(node, `Argument of type \`${checker.typeToString(argumentType)}\` is assignable to parameter of type \`${checker.typeToString(expectedType)}\`.`));
}
}

return diagnostics;
};
1 change: 1 addition & 0 deletions source/lib/assertions/handlers/index.ts
Expand Up @@ -2,3 +2,4 @@ export {Handler} from './handler';

// Handlers
export {strictAssertion} from './strict-assertion';
export {isNotAssignable} from './assignability';
8 changes: 6 additions & 2 deletions source/lib/assertions/index.ts
Expand Up @@ -2,15 +2,19 @@ import {CallExpression} from '../../../libraries/typescript/lib/typescript';
import {TypeChecker} from '../entities/typescript';
import {Diagnostic} from '../interfaces';
import {Handler, strictAssertion} from './handlers';
import {isNotAssignable} from './handlers/assignability';

export enum Assertion {
EXPECT_TYPE = 'expectType',
EXPECT_ERROR = 'expectError'
EXPECT_ERROR = 'expectError',
EXPECT_ASSIGNABLE = 'expectAssignable',
EXPECT_NOT_ASSIGNABLE = 'expectNotAssignable'
}

// List of diagnostic handlers attached to the assertion
const assertionHandlers = new Map<string, Handler | Handler[]>([
[Assertion.EXPECT_TYPE, strictAssertion]
[Assertion.EXPECT_TYPE, strictAssertion],
[Assertion.EXPECT_NOT_ASSIGNABLE, isNotAssignable]
]);

/**
Expand Down
21 changes: 21 additions & 0 deletions source/test/assignability.ts
@@ -0,0 +1,21 @@
import * as path from 'path';
import test from 'ava';
import {verify} from './fixtures/utils';
import tsd from '..';

test('assignable', async t => {
const diagnostics = await tsd({cwd: path.join(__dirname, 'fixtures/assignability/assignable')});

verify(t, diagnostics, [
[8, 26, 'error', 'Argument of type \'string\' is not assignable to parameter of type \'boolean\'.']
]);
});

test('not assignable', async t => {
const diagnostics = await tsd({cwd: path.join(__dirname, 'fixtures/assignability/not-assignable')});

verify(t, diagnostics, [
[4, 0, 'error', 'Argument of type `string` is assignable to parameter of type `string | number`.'],
[5, 0, 'error', 'Argument of type `string` is assignable to parameter of type `any`.'],
]);
});
6 changes: 6 additions & 0 deletions source/test/fixtures/assignability/assignable/index.d.ts
@@ -0,0 +1,6 @@
declare const concat: {
(foo: string, bar: string): string;
(foo: number, bar: number): number;
};

export default concat;
3 changes: 3 additions & 0 deletions source/test/fixtures/assignability/assignable/index.js
@@ -0,0 +1,3 @@
module.exports.default = (foo, bar) => {
return foo + bar;
};
8 changes: 8 additions & 0 deletions source/test/fixtures/assignability/assignable/index.test-d.ts
@@ -0,0 +1,8 @@
import {expectAssignable} from '../../../..';
import concat from '.';

expectAssignable<string | number>(concat('foo', 'bar'));
expectAssignable<string | number>(concat(1, 2));
expectAssignable<any>(concat(1, 2));

expectAssignable<boolean>(concat('unicorn', 'rainbow'));
3 changes: 3 additions & 0 deletions source/test/fixtures/assignability/assignable/package.json
@@ -0,0 +1,3 @@
{
"name": "foo"
}
6 changes: 6 additions & 0 deletions source/test/fixtures/assignability/not-assignable/index.d.ts
@@ -0,0 +1,6 @@
declare const concat: {
(foo: string, bar: string): string;
(foo: number, bar: number): number;
};

export default concat;
3 changes: 3 additions & 0 deletions source/test/fixtures/assignability/not-assignable/index.js
@@ -0,0 +1,3 @@
module.exports.default = (foo, bar) => {
return foo + bar;
};
@@ -0,0 +1,8 @@
import {expectNotAssignable} from '../../../..';
import concat from '.';

expectNotAssignable<string | number>(concat('foo', 'bar'));
expectNotAssignable<any>(concat('foo', 'bar'));

expectNotAssignable<boolean>(concat('unicorn', 'rainbow'));
expectNotAssignable<symbol>(concat('unicorn', 'rainbow'));
@@ -0,0 +1,3 @@
{
"name": "foo"
}
30 changes: 30 additions & 0 deletions source/test/fixtures/utils.ts
@@ -0,0 +1,30 @@
import {ExecutionContext} from 'ava';
import {Diagnostic} from '../../lib/interfaces';

type Expectation = [number, number, 'error' | 'warning', string, (string | RegExp)?];

/**
* Verify a list of diagnostics.
*
* @param t - The AVA execution context.
* @param diagnostics - List of diagnostics to verify.
* @param expectations - Expected diagnostics.
*/
export const verify = (t: ExecutionContext, diagnostics: Diagnostic[], expectations: Expectation[]) => {
t.true(diagnostics.length === expectations.length);

for (const [index, diagnostic] of diagnostics.entries()) {
t.is(diagnostic.line, expectations[index][0]);
t.is(diagnostic.column, expectations[index][1]);
t.is(diagnostic.severity, expectations[index][2]);
t.is(diagnostic.message, expectations[index][3]);

const filename = expectations[index][4];

if (typeof filename === 'string') {
t.is(diagnostic.fileName, filename);
} else if (typeof filename === 'object') {
t.regex(diagnostic.fileName, filename);
}
}
};
32 changes: 2 additions & 30 deletions source/test/test.ts
@@ -1,35 +1,7 @@
import * as path from 'path';
import test, {ExecutionContext} from 'ava';
import test from 'ava';
import {verify} from './fixtures/utils';
import tsd from '..';
import {Diagnostic} from '../lib/interfaces';

type Expectation = [number, number, 'error' | 'warning', string, (string | RegExp)?];

/**
* Verify a list of diagnostics.
*
* @param t - The AVA execution context.
* @param diagnostics - List of diagnostics to verify.
* @param expectations - Expected diagnostics.
*/
const verify = (t: ExecutionContext, diagnostics: Diagnostic[], expectations: Expectation[]) => {
t.true(diagnostics.length === expectations.length);

for (const [index, diagnostic] of diagnostics.entries()) {
t.is(diagnostic.line, expectations[index][0]);
t.is(diagnostic.column, expectations[index][1]);
t.is(diagnostic.severity, expectations[index][2]);
t.is(diagnostic.message, expectations[index][3]);

const filename = expectations[index][4];

if (typeof filename === 'string') {
t.is(diagnostic.fileName, filename);
} else if (typeof filename === 'object') {
t.regex(diagnostic.fileName, filename);
}
}
};

test('throw if no type definition was found', async t => {
await t.throwsAsync(tsd({cwd: path.join(__dirname, 'fixtures/no-tsd')}), 'The type definition `index.d.ts` does not exist. Create one and try again.');
Expand Down

0 comments on commit 3a375fa

Please sign in to comment.