├── .editorconfig ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── SECURITY.md ├── examples └── test-definition │ ├── example.d.ts │ ├── example.js │ ├── package-lock.json │ ├── package.json │ ├── test.ts │ └── tsconfig.json ├── package-lock.json ├── package.json ├── src ├── __fixtures__ │ ├── .gitignore │ ├── test-import-spread.ts │ ├── test-import-star.ts │ └── test-var-shadow.ts ├── __snapshots__ │ └── transform.spec.ts.snap ├── index.spec.ts ├── index.ts ├── transform.spec.ts └── transform.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_size = 2 7 | indent_style = space 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | npm-debug.log 3 | coverage/ 4 | node_modules/ 5 | dist/ 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | 4 | notifications: 5 | email: 6 | on_success: never 7 | on_failure: change 8 | 9 | node_js: 10 | - "10" 11 | - "stable" 12 | 13 | after_script: "npm install coveralls@2 && cat ./coverage/lcov.info | coveralls" 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Blake Embrey (hello@blakeembrey.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TS Expect 2 | 3 | [![NPM version][npm-image]][npm-url] 4 | [![NPM downloads][downloads-image]][downloads-url] 5 | [![Build status][travis-image]][travis-url] 6 | [![Test coverage][coveralls-image]][coveralls-url] 7 | 8 | > Checks values in TypeScript match expectations. 9 | 10 | ## Installation 11 | 12 | ```sh 13 | npm install ts-expect --save 14 | ``` 15 | 16 | ## Usage 17 | 18 | **TS Expect** exports a function, named `expectType`, that does _nothing at all_. Instead, it depends on the TypeScript compiler and a generic to test the type of a "value" passed to `expectType` is assignable to its generic in the type system. 19 | 20 | ```ts 21 | import { expectType } from "ts-expect"; 22 | 23 | expectType("test"); 24 | expectType(123); 25 | expectType("test"); // Compiler error! 26 | ``` 27 | 28 | ### How does this work? 29 | 30 | TypeScript generics allow you to pass any value that implements the generic type. In this case, we're defining the generic explicitly as we pass the value so any value that isn't implementing our type is rejected by the TypeScript compiler. It's really that simple! The technical implementation is just `(value: T) => void`. 31 | 32 | TypeScript has a ["top type"](https://en.wikipedia.org/wiki/Top_type) named `unknown` and a ["bottom type"](https://en.wikipedia.org/wiki/Bottom_type) named `never`. Using the top type to check assignability would mean every value is accepted, and the bottom type would mean nothing is accepted (except `never` itself). As a result, you probably wouldn't want to use `unknown` because everything would pass that check. 33 | 34 | A quick note on `any`: it's an "off switch" for TypeScript. It acts as a magical every type, both a top and a bottom type. This means it's assignable to everything and passing an `any` value to `expectType` will always pass the check. 35 | 36 | ### Testing definitions 37 | 38 | Use with built-in or custom TypeScript [utility types](https://www.typescriptlang.org/docs/handbook/utility-types.html) to implement a simple testing framework for your type definitions. If it compiles, it's valid! 39 | 40 | ```ts 41 | import { expectType, TypeEqual } from "ts-expect"; 42 | import { add } from "./adder"; 43 | 44 | expectType(add(1, 2)); 45 | expectType>>(true); 46 | expectType>>(true); 47 | ``` 48 | 49 | ### Exhaustive checks 50 | 51 | Use with TypeScript's [type narrowing](https://sandersn.github.io/manual/Widening-and-Narrowing-in-Typescript.html) to test that `value` is what you expect. If you expand `SupportedValue` with other values in the future, it'll fail an `expectType` or `expectNever` check because you haven't used all the possible values. 52 | 53 | ```ts 54 | import { expectNever } from "ts-expect"; 55 | 56 | type SupportedValue = "a" | "b"; 57 | 58 | function doSomething(value: SupportedValue) { 59 | switch (value) { 60 | case "a": 61 | return true; 62 | case "b": 63 | return true; 64 | default: 65 | return expectNever(value); 66 | } 67 | } 68 | ``` 69 | 70 | **Tip**: Use `expectNever(value)` when you need to return `never` (i.e. throw an error if the code runs), use `expectType(value)` when you want to do tests in your code and expect the actual expression to be executed (i.e. do type checks but ignore the runtime). 71 | 72 | ## Exported Types 73 | 74 | **TS Expect** comes with some utility types built-in to make testing easier. File [an issue](https://github.com/TypeStrong/ts-expect/issues) if you think something is missing! 75 | 76 | ### TypeEqual 77 | 78 | Checks that `Value` is equal to the same type as `Target`. This is a stricter check that avoids issues with testing sub-types. If you want to verify that an object is identical shape, not just "implements" `Target`, this is the type you need. 79 | 80 | ### TypeOf 81 | 82 | Checks that `Value` is assignable to `Target`. This is effectively the same as `expectType(value)`, except it's implemented in the type system directly so you can use it to test types instead of values by checking the result is `true` or `false`. 83 | 84 | ## Prior Works 85 | 86 | Some great prior works have been mentioned after publishing this package: 87 | 88 | - [`dtslint`](https://github.com/Microsoft/dtslint) does type checks via comment directives and [inspired](https://github.com/Microsoft/dtslint/issues/126) this approach of using the compiler 89 | - [`tsd-check`](https://github.com/SamVerschueren/tsd-check/issues/10) is a CLI that runs the TypeScript type checker over assertions 90 | - [`type-plus`](https://github.com/unional/type-plus) comes with various type and runtime TypeScript assertions 91 | - [`static-type-assert`](https://github.com/ksxnodemodules/static-type-assert) exposes a similar API surface with some type assertion functions 92 | 93 | ## License 94 | 95 | MIT 96 | 97 | [npm-image]: https://img.shields.io/npm/v/ts-expect.svg?style=flat 98 | [npm-url]: https://npmjs.org/package/ts-expect 99 | [downloads-image]: https://img.shields.io/npm/dm/ts-expect.svg?style=flat 100 | [downloads-url]: https://npmjs.org/package/ts-expect 101 | [travis-image]: https://img.shields.io/travis/TypeStrong/ts-expect.svg?style=flat 102 | [travis-url]: https://travis-ci.org/TypeStrong/ts-expect 103 | [coveralls-image]: https://img.shields.io/coveralls/TypeStrong/ts-expect.svg?style=flat 104 | [coveralls-url]: https://coveralls.io/r/TypeStrong/ts-expect?branch=master 105 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Security contact information 4 | 5 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. 6 | -------------------------------------------------------------------------------- /examples/test-definition/example.d.ts: -------------------------------------------------------------------------------- 1 | export class Foo { 2 | method(): boolean; 3 | } 4 | -------------------------------------------------------------------------------- /examples/test-definition/example.js: -------------------------------------------------------------------------------- 1 | // Explicitly inconsistent with `.d.ts` to verify `tsc` is using this file. 2 | module.exports = false; 3 | -------------------------------------------------------------------------------- /examples/test-definition/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "requires": true, 3 | "lockfileVersion": 1, 4 | "dependencies": { 5 | "typescript": { 6 | "version": "3.4.5", 7 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.4.5.tgz", 8 | "integrity": "sha512-YycBxUb49UUhdNMU5aJ7z5Ej2XGmaIBL0x34vZ82fn3hGvD+bgrMrVDpatgz2f7YxUMJxMkbWxJZeAvDxVe7Vw==", 9 | "dev": true 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /examples/test-definition/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "test": "tsc" 5 | }, 6 | "devDependencies": { 7 | "typescript": "^3.2.4" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /examples/test-definition/test.ts: -------------------------------------------------------------------------------- 1 | import { expectType, TypeEqual } from "../.."; 2 | import { Foo } from './example' 3 | 4 | expectType(new Foo()); 5 | expectType>>(true); 6 | expectType>>(true); 7 | -------------------------------------------------------------------------------- /examples/test-definition/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "module": "commonjs", 5 | "rootDir": ".", 6 | "outDir": "dist", 7 | "strict": true, 8 | "sourceMap": true, 9 | "inlineSources": true, 10 | "allowJs": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ts-expect", 3 | "version": "1.3.0", 4 | "publishConfig": { 5 | "access": "public" 6 | }, 7 | "description": "Checks TypeScript types match expected values", 8 | "license": "MIT", 9 | "repository": { 10 | "type": "git", 11 | "url": "git://github.com/TypeStrong/ts-expect.git" 12 | }, 13 | "author": { 14 | "name": "Blake Embrey", 15 | "email": "hello@blakeembrey.com", 16 | "url": "http://blakeembrey.me" 17 | }, 18 | "homepage": "https://github.com/TypeStrong/ts-expect", 19 | "bugs": { 20 | "url": "https://github.com/TypeStrong/ts-expect/issues" 21 | }, 22 | "main": "dist/index.js", 23 | "scripts": { 24 | "build": "ts-scripts build", 25 | "format": "ts-scripts format", 26 | "lint": "ts-scripts lint", 27 | "prepare": "ts-scripts install", 28 | "prepublishOnly": "npm run build", 29 | "specs": "ts-scripts specs", 30 | "test": "ts-scripts test" 31 | }, 32 | "files": [ 33 | "dist/", 34 | "LICENSE" 35 | ], 36 | "keywords": [ 37 | "typescript", 38 | "type-check", 39 | "assert", 40 | "expect", 41 | "type", 42 | "check", 43 | "types", 44 | "typings" 45 | ], 46 | "devDependencies": { 47 | "@borderless/ts-scripts": "^0.12.0", 48 | "@jest/globals": "^28.1.1", 49 | "@types/node": "^14.18.21", 50 | "typescript": "^4.7.3" 51 | }, 52 | "typings": "dist/index.d.ts" 53 | } 54 | -------------------------------------------------------------------------------- /src/__fixtures__/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | -------------------------------------------------------------------------------- /src/__fixtures__/test-import-spread.ts: -------------------------------------------------------------------------------- 1 | import { expectType, TypeOf } from "ts-expect"; 2 | 3 | expectType>(true); 4 | -------------------------------------------------------------------------------- /src/__fixtures__/test-import-star.ts: -------------------------------------------------------------------------------- 1 | import * as expect from "ts-expect"; 2 | 3 | expect.expectType(123); 4 | -------------------------------------------------------------------------------- /src/__fixtures__/test-var-shadow.ts: -------------------------------------------------------------------------------- 1 | import { expectType } from "ts-expect"; 2 | 3 | function test(expectType: any) { 4 | return expectType(); 5 | } 6 | 7 | const test2 = (expectType: any) => expectType(); 8 | 9 | function test3(noShadow: any) { 10 | expectType(123); 11 | return noShadow; 12 | } 13 | 14 | test(expectType); 15 | test2(() => undefined); 16 | test3(123); 17 | 18 | expectType(""); 19 | 20 | const expectedType = expectType; 21 | 22 | expectedType(""); 23 | -------------------------------------------------------------------------------- /src/__snapshots__/transform.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`transform should strip expects 1`] = ` 4 | "\\"use strict\\"; 5 | exports.__esModule = true; 6 | var ts_expect_1 = require(\\"ts-expect\\"); 7 | function test(expectType) { 8 | return expectType(); 9 | } 10 | var test2 = function (expectType) { return expectType(); }; 11 | function test3(noShadow) { 12 | void 0; 13 | return noShadow; 14 | } 15 | test(ts_expect_1.expectType); 16 | test2(function () { return undefined; }); 17 | test3(123); 18 | void 0; 19 | var expectedType = ts_expect_1.expectType; 20 | expectedType(\\"\\"); 21 | " 22 | `; 23 | 24 | exports[`transform should strip expects 2`] = ` 25 | "\\"use strict\\"; 26 | exports.__esModule = true; 27 | var ts_expect_1 = require(\\"ts-expect\\"); 28 | void 0; 29 | " 30 | `; 31 | 32 | exports[`transform should strip expects 3`] = ` 33 | "\\"use strict\\"; 34 | exports.__esModule = true; 35 | var expect = require(\\"ts-expect\\"); 36 | void 0; 37 | " 38 | `; 39 | -------------------------------------------------------------------------------- /src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "@jest/globals"; 2 | import { expectType, expectNever, TypeOf, TypeEqual } from "./index"; 3 | 4 | describe("ts expect", () => { 5 | it("should expect types", () => { 6 | expectType(""); 7 | expectType(123); 8 | }); 9 | 10 | it("should return void", () => { 11 | const result = expectType(""); 12 | 13 | expect(result).toEqual(undefined); 14 | }); 15 | 16 | describe("expectNever", () => { 17 | type SupportedValue = "a" | "b"; 18 | 19 | function doSomething(value: SupportedValue): boolean { 20 | switch (value) { 21 | case "a": 22 | return true; 23 | case "b": 24 | return true; 25 | default: 26 | return expectNever(value); 27 | } 28 | } 29 | 30 | it("should support exhaustive check", () => { 31 | expectType>>(true); 32 | }); 33 | 34 | it("should throw if called", () => { 35 | expect(expectNever).toThrowError(TypeError); 36 | }); 37 | }); 38 | 39 | describe("TypeOf", () => { 40 | it("should support type of checks", () => { 41 | expectType>(true); 42 | expectType>(false); 43 | expectType>(true); 44 | expectType>(false); 45 | }); 46 | }); 47 | 48 | describe("TypeEqual", () => { 49 | it("should check types are equal", () => { 50 | expectType>(true); 51 | expectType>(false); 52 | expectType>(false); 53 | expectType>(true); 54 | 55 | expectType>(false); 56 | expectType>(false); 57 | 58 | expectType>(false); 59 | expectType>(true); 60 | 61 | expectType>(true); 62 | }); 63 | 64 | it("should check for `any` type", () => { 65 | expectType>(false); 66 | expectType>(false); 67 | expectType>(false); 68 | expectType>(false); 69 | expectType>>(false); 70 | expectType>(false); 71 | expectType>(true); 72 | 73 | expectType, false>>(true); 74 | }); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Checks that `Value` is assignable to `Target`. 3 | * 4 | * ```ts 5 | * expectType>(true); 6 | * expectType>(false); 7 | * ``` 8 | */ 9 | export type TypeOf = Exclude extends never 10 | ? true 11 | : false; 12 | 13 | /** 14 | * Checks that `Value` is equal to the same type as `Target`. 15 | * 16 | * ```ts 17 | * expectType>(true); 18 | * expectType>(false); 19 | * expectType>(false); 20 | * expectType>(true); 21 | * ``` 22 | */ 23 | export type TypeEqual = (() => T extends Target 24 | ? 1 25 | : 2) extends () => T extends Value ? 1 : 2 26 | ? true 27 | : false; 28 | 29 | /** 30 | * Asserts the `value` type is assignable to the generic `Type`. 31 | * 32 | * ```ts 33 | * expectType(123); 34 | * expectType(true); 35 | * ``` 36 | */ 37 | export const expectType = (_: Type): void => void 0; 38 | 39 | /** 40 | * Asserts the `value` type is `never`, i.e. this function should never be called. 41 | * If it is called at runtime, it will throw a `TypeError`. The return type is 42 | * `never` to support returning in exhaustive type checks. 43 | * 44 | * ```ts 45 | * return expectNever(value); 46 | * ``` 47 | */ 48 | export const expectNever = (value: never): never => { 49 | throw new TypeError("Unexpected value: " + value); 50 | }; 51 | -------------------------------------------------------------------------------- /src/transform.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "@jest/globals"; 2 | import ts from "typescript"; 3 | import transform from "./transform"; 4 | import { readFileSync } from "fs"; 5 | 6 | describe("transform", () => { 7 | const paths = [ 8 | new URL("__fixtures__/test-var-shadow.ts", import.meta.url), 9 | new URL("__fixtures__/test-import-spread.ts", import.meta.url), 10 | new URL("__fixtures__/test-import-star.ts", import.meta.url), 11 | ]; 12 | 13 | it("should strip expects", () => { 14 | const host = ts.createCompilerHost({}); 15 | const program = ts.createProgram( 16 | paths.map((x) => x.pathname), 17 | {}, 18 | host 19 | ); 20 | const result = program.emit(undefined, undefined, undefined, undefined, { 21 | before: [transform()], 22 | }); 23 | 24 | expect(result.diagnostics).toEqual([]); 25 | 26 | for (const path of paths) { 27 | const outPath = path.pathname.replace(/\.ts$/, ".js"); 28 | 29 | expect(readFileSync(outPath, "utf8")).toMatchSnapshot(); 30 | } 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/transform.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | 3 | /** 4 | * Strip TypeScript expectations from runtime code. 5 | */ 6 | export default function (): ts.TransformerFactory { 7 | function visitor(context: ts.TransformationContext): ts.Visitor { 8 | let keywords = new Set(); 9 | 10 | return function visit(node: ts.Node): ts.VisitResult { 11 | if (ts.isImportDeclaration(node)) { 12 | const importName = node.moduleSpecifier.getText().slice(1, -1); 13 | 14 | if (importName === "ts-expect" && node.importClause) { 15 | const { namedBindings } = node.importClause; 16 | 17 | if (namedBindings) { 18 | ts.forEachChild(namedBindings, (x) => keywords.add(x.getText())); 19 | } 20 | 21 | return node; // Let minifier handle this. 22 | } 23 | } 24 | 25 | if (ts.isFunctionLike(node)) { 26 | const oldKeywords = new Set(keywords); 27 | 28 | // Remove shadowed keywords. 29 | node.parameters 30 | .map((x) => x.name.getText()) 31 | .filter((x) => keywords.has(x)) 32 | .forEach((x) => keywords.delete(x)); 33 | 34 | const result = ts.visitEachChild(node, visit, context); 35 | keywords = oldKeywords; // Restore keywords. 36 | return result; 37 | } 38 | 39 | if (ts.isCallExpression(node)) { 40 | if (keywords.has(node.expression.getText())) { 41 | return ts.factory.createVoidZero(); 42 | } 43 | 44 | const token = node.expression.getFirstToken(); 45 | if (token && keywords.has(token.getText())) { 46 | return ts.factory.createVoidZero(); 47 | } 48 | 49 | return node; 50 | } 51 | 52 | return ts.visitEachChild(node, visit, context); 53 | }; 54 | } 55 | 56 | return function transformer(context: ts.TransformationContext) { 57 | return (sourceFile: ts.SourceFile) => 58 | ts.visitNode(sourceFile, visitor(context)); 59 | }; 60 | } 61 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@borderless/ts-scripts/configs/tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "rootDir": "src", 6 | "baseUrl": "src", 7 | "types": ["node"], 8 | "paths": { 9 | "ts-expect": ["."] 10 | } 11 | }, 12 | "include": ["./src/**/*"] 13 | } 14 | --------------------------------------------------------------------------------