├── .commitlintrc.json ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .lintstagedrc.js ├── .npmignore ├── .prettierrc.json ├── .versionrc.json ├── CHANGELOG.md ├── README.md ├── index.test-d.ts ├── index.test.ts ├── index.ts ├── jest.config.js ├── package-lock.json ├── package.json ├── tsconfig.json └── tsconfig.test.json /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .eslintrc.js 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true, 6 | jest: true, 7 | }, 8 | extends: [ 9 | 'eslint:recommended', 10 | 'plugin:@typescript-eslint/eslint-recommended', 11 | 'plugin:@typescript-eslint/recommended', 12 | 'prettier', 13 | 'prettier/@typescript-eslint', 14 | 'plugin:prettier/recommended', 15 | ], 16 | parser: '@typescript-eslint/parser', 17 | parserOptions: { 18 | ecmaVersion: 12, 19 | sourceType: 'module', 20 | }, 21 | plugins: ['@typescript-eslint'], 22 | rules: {}, 23 | }; 24 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | branches: [ master ] 5 | pull_request: 6 | branches: [ master ] 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [12.x, 15.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v1 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | - run: npm ci 24 | - run: npm run build 25 | - run: npm test 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /.idea/ 3 | /index.js 4 | /index.d.ts 5 | /index.test.d.ts 6 | /index.test.js 7 | /index.test-d.d.ts 8 | /index.test-d.js 9 | /coverage 10 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit $1 -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install lint-staged --relative --concurrent=1 -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | // The configuration object for [lint-staged](https://www.npmjs.com/package/lint-staged) 2 | module.exports = { 3 | // Lint source with [ESLint](https://www.npmjs.com/package/eslint) 4 | 'index.ts': 'eslint --fix', 5 | // Format all files with [prettier](https://www.npmjs.com/package/prettier). 6 | '*.{ts,js,json,md}': 'prettier --write', 7 | }; 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /.eslintrc.js 3 | /.gitignore 4 | /.lintstagedrc.js 5 | /.versionrc.json 6 | /jest.config.js 7 | /tsconfig.json 8 | /tsconfig.test.json 9 | /index.ts 10 | /index.test.ts 11 | /index.test.d.ts 12 | /index.test.js 13 | /index.test-d.ts 14 | /index.test-d.d.ts 15 | /index.test-d.js 16 | /coverage 17 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "es5" 5 | } 6 | -------------------------------------------------------------------------------- /.versionrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "header": "", 4 | "types": [ 5 | { 6 | "type": "feat", 7 | "section": "Features" 8 | }, 9 | { 10 | "type": "fix", 11 | "section": "Bug Fixes" 12 | }, 13 | { 14 | "type": "chore", 15 | "hidden": true 16 | }, 17 | { 18 | "type": "docs", 19 | "hidden": true 20 | }, 21 | { 22 | "type": "style", 23 | "hidden": true 24 | }, 25 | { 26 | "type": "refactor", 27 | "section": "Code Refactoring" 28 | }, 29 | { 30 | "type": "perf", 31 | "section": "Performance Improvements" 32 | }, 33 | { 34 | "type": "test", 35 | "hidden": true 36 | }, 37 | { 38 | "type": "build", 39 | "hidden": true 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | ## 1.0.0 (2020-10-30) 3 | 4 | 5 | ### Features 6 | 7 | * add NonNullish variations for core contracts ([#3](https://github.com/JanMalch/ts-code-contracts/issues/3)) ([93ae61d](https://github.com/JanMalch/ts-code-contracts/commit/93ae61df6f8c941a903fd6af61bd4f28cbb17889)) 8 | * add optional message to unreachable ([44d3fb0](https://github.com/JanMalch/ts-code-contracts/commit/44d3fb008d30b638402819fa73d9bc9efb5070c9)) 9 | * initial commit ([2b4a3c6](https://github.com/JanMalch/ts-code-contracts/commit/2b4a3c6a960e9598f6ddc9c0a6e448a1c9fe064e)) 10 | * remove useIf and improve error ([#5](https://github.com/JanMalch/ts-code-contracts/issues/5)) ([c4ceaf3](https://github.com/JanMalch/ts-code-contracts/commit/c4ceaf358a29a16726a3238a3f9b2713244d663a)) 11 | * use IllegalStateError instead of AssertionError ([cc16ac3](https://github.com/JanMalch/ts-code-contracts/commit/cc16ac3549ad888f18a05c0233028530356e664c)) 12 | 13 | 14 | ### Bug Fixes 15 | 16 | * adjust meaning of asserts ([32e4c67](https://github.com/JanMalch/ts-code-contracts/commit/32e4c6787d3c63f3d77c9cdf17445bcdbf270d72)) 17 | * split error declarations and fix implementation ([e0daabb](https://github.com/JanMalch/ts-code-contracts/commit/e0daabbd73b15282d7cdd89f8cfa360ee3ab8130)) 18 | * use AssertionError for unreachable ([ef6a023](https://github.com/JanMalch/ts-code-contracts/commit/ef6a023fe2a4421d63a39daa912de78046dbe7ba)) 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ts-code-contracts 2 | 3 | [![npm](https://img.shields.io/npm/v/ts-code-contracts)](https://www.npmjs.com/package/ts-code-contracts) 4 | [![Build](https://github.com/JanMalch/ts-code-contracts/workflows/Build/badge.svg)](https://github.com/JanMalch/ts-code-contracts/workflows/Build) 5 | [![coverage](https://img.shields.io/badge/coverage-100%25-success)](https://github.com/JanMalch/ts-code-contracts/blob/master/jest.config.js#L14-L17) 6 | [![minified + gzip](https://badgen.net/bundlephobia/minzip/ts-code-contracts)](https://bundlephobia.com/result?p=ts-code-contracts) 7 | 8 | _Design by contract with TypeScript._ 9 | 10 | ## Installation & Usage 11 | 12 | ``` 13 | npm i ts-code-contracts 14 | ``` 15 | 16 | > Requires TypeScript^3.7 17 | 18 | You can now import the following functions `from 'ts-code-contracts'`: 19 | 20 | - [`requires` for preconditions](#requires) 21 | - [`requiresNonNullish` for null-checks as preconditions](#requiresnonnullish) 22 | - [`checks` for invariants](#checks) 23 | - [`checksNonNullish` for null-checks as invariants](#checksnonnullish) 24 | - [`ensures` for postconditions](#ensures) 25 | - [`ensuresNonNullish` for null-checks as postconditions](#ensuresnonnullish) 26 | - [`asserts` for impossible events](#asserts) 27 | - [`unreachable` for unreachable code branches](#unreachable) 28 | - [`error` to make code more concise](#error) 29 | - [`isDefined` type guard](#isdefined) 30 | 31 | Make sure to checkout the examples in the documentation below 32 | or refer to the [test cases](https://github.com/JanMalch/ts-code-contracts/blob/master/index.test.ts#L133-L163) 33 | and [typing assistance](https://github.com/JanMalch/ts-code-contracts/blob/master/index.test-d.ts#L41-L52)! 34 | 35 | Contracts are really just handy shorthands to throw an error, if the given condition is not met. 36 | And yet they greatly help the compiler and the readability of your code. 37 | 38 | ## `requires` 39 | 40 | Use it to validate preconditions, like validating arguments. 41 | Throws a `PreconditionError` if the `condition` is `false`. 42 | 43 | ```ts 44 | function requires( 45 | condition: boolean, 46 | message: string = 'Unmet precondition' 47 | ): asserts condition; 48 | ``` 49 | 50 | - `condition` - the condition that should be `true` 51 | - `message` - an optional message for the error 52 | 53 | **Example:** 54 | 55 | ```ts 56 | function myFun(name: string) { 57 | requires(name.length > 10, 'Name must be longer than 10 chars'); 58 | } 59 | ``` 60 | 61 | ## `requiresNonNullish` 62 | 63 | A variation of `requires` that returns the given value unchanged if it is not `null` or `undefined`. 64 | Throws a `PreconditionError` otherwise. 65 | 66 | ```ts 67 | function requiresNonNullish( 68 | value: T, 69 | message = 'Value must not be null or undefined' 70 | ): NonNullable; 71 | ``` 72 | 73 | - `value` - the value that should not be `null` or `undefined` 74 | - `message` - an optional message for the error 75 | 76 | **Example:** 77 | 78 | ```ts 79 | function myFun(name: string | null) { 80 | const nameNonNull = requiresNonNullish(name, 'Name must be defined'); 81 | nameNonNull.toUpperCase(); // no compiler error! 82 | } 83 | ``` 84 | 85 | ## `checks` 86 | 87 | Use it to check for an illegal state. 88 | Throws a `IllegalStateError` if the `condition` is `false`. 89 | 90 | ```ts 91 | function checks( 92 | condition: boolean, 93 | message = 'Callee invariant violation' 94 | ): asserts condition; 95 | ``` 96 | 97 | - `condition` - the condition that should be `true` 98 | - `message` - an optional message for the error 99 | 100 | **Example:** 101 | 102 | ```ts 103 | class Socket { 104 | private isOpen = false; 105 | send(data: Data) { 106 | check(this.isOpen, 'Socket must be open'); 107 | } 108 | open() { 109 | this.isOpen = true; 110 | } 111 | } 112 | ``` 113 | 114 | ## `checksNonNullish` 115 | 116 | A variation of `checks` that returns the given value unchanged if it is not `null` or `undefined`. 117 | Throws a `IllegalStateError` otherwise. 118 | 119 | ```ts 120 | function checksNonNullish( 121 | value: T, 122 | message = 'Value must not be null or undefined' 123 | ): NonNullable; 124 | ``` 125 | 126 | - `value` - the value that should not be `null` or `undefined` 127 | - `message` - an optional message for the error 128 | 129 | **Example:** 130 | 131 | ```ts 132 | class Socket { 133 | data: Data | null = null; 134 | send() { 135 | const validData = checksNonNullish(this.data, 'Data must be available'); 136 | validData.send(); // no compiler error! 137 | } 138 | } 139 | ``` 140 | 141 | ## `ensures` 142 | 143 | Use it to verify that your code behaved correctly. 144 | Throws a `PostconditionError` if the `condition` is `false`. 145 | 146 | ```ts 147 | function ensures( 148 | condition: boolean, 149 | message = 'Unmet postcondition' 150 | ): asserts condition; 151 | ``` 152 | 153 | - `condition` - the condition that should be `true` 154 | - `message` - an optional message for the error 155 | 156 | **Example:** 157 | 158 | ```ts 159 | function myFun() { 160 | createPerson({ id: 0, name: 'John' }); 161 | const entity = findById(0); // returns null if not present 162 | return ensures(isDefined(entity), 'Failed to persist entity'); 163 | } 164 | ``` 165 | 166 | ## `ensuresNonNullish` 167 | 168 | A variation of `ensures` that returns the given value unchanged if it is not `null` or `undefined`. 169 | Throws a `PostconditionError` otherwise. 170 | 171 | ```ts 172 | function ensuresNonNullish( 173 | value: T, 174 | message = 'Value must not be null or undefined' 175 | ): NonNullable; 176 | ``` 177 | 178 | - `value` - the value that should not be `null` or `undefined` 179 | - `message` - an optional message for the error 180 | 181 | **Example:** 182 | 183 | ```ts 184 | function myFun(): Person { 185 | createPerson({ id: 0, name: 'John' }); 186 | const entity = findById(0); // returns null if not present 187 | return ensuresNonNullish(entity, 'Failed to persist entity'); 188 | } 189 | ``` 190 | 191 | ## `asserts` 192 | 193 | Clarify that you think that the given condition is impossible to happen. 194 | Throws a `AssertionError` if the `condition` is `false`. 195 | 196 | ```ts 197 | asserts( 198 | condition: boolean, 199 | message?: string 200 | ): asserts condition; 201 | ``` 202 | 203 | - `condition` - the condition that should be `true` 204 | - `message` - an optional message for the error 205 | 206 | ## `unreachable` 207 | 208 | Asserts that a code branch is unreachable. If it is, the compiler will throw a type error. 209 | If this function is reached at runtime, an error will be thrown. 210 | 211 | ```ts 212 | function unreachable( 213 | value: never, 214 | message = 'Reached an unreachable case' 215 | ): never; 216 | ``` 217 | 218 | - `value` - a value 219 | - `message` - an optional message for the error 220 | 221 | **Example:** 222 | 223 | ```ts 224 | function myFun(foo: MyEnum): string { 225 | switch (foo) { 226 | case MyEnum.A: 227 | return 'a'; 228 | case MyEnum.B: 229 | return 'b'; 230 | // no compiler error if MyEnum only has A and B 231 | default: 232 | unreachable(foo); 233 | } 234 | } 235 | ``` 236 | 237 | ## `error` 238 | 239 | This function will always throw an error. 240 | It helps keeping code easy to read and come in handy when assigning values with a ternary operator or the null-safe operators. 241 | 242 | ```ts 243 | function error(message?: string): never; 244 | function error( 245 | errorType: new (...args: any[]) => Error, 246 | message?: string 247 | ): never; 248 | ``` 249 | 250 | - `errorType` - an error class, defaults to `IllegalStateError` 251 | - `message` - an optional message for the error 252 | 253 | **Example:** 254 | 255 | ```ts 256 | function myFun(foo: string | null) { 257 | const bar = foo ?? error(PreconditionError, 'Argument may not be null'); 258 | const result = bar.length > 0 ? 'OK' : error('Something went wrong!'); 259 | } 260 | ``` 261 | 262 | ## `isDefined` 263 | 264 | A type guard, to check that a value is not `null` or `undefined`. 265 | Make sure to use [`strictNullChecks`](https://basarat.gitbook.io/typescript/intro/strictnullchecks). 266 | 267 | ```ts 268 | function isDefined(value: T): value is NonNullable; 269 | ``` 270 | 271 | - `value` - the value to test 272 | 273 | **Example:** 274 | 275 | ```ts 276 | const x: string | null = 'Hello'; 277 | if (isDefined(x)) { 278 | x.toLowerCase(); // no compiler error! 279 | } 280 | ``` 281 | 282 | ## Errors 283 | 284 | The following error classes are included: 285 | 286 | - `PreconditionError` → An error thrown, if a precondition for a function or method is not met. 287 | - `IllegalStateError` → An error thrown, if an object is an illegal state. 288 | - `PostconditionError` → An error thrown, if a function or method could not fulfil a postcondition. 289 | - `AssertionError` → An error thrown, if an assertion has failed. 290 | -------------------------------------------------------------------------------- /index.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectType } from 'tsd'; 2 | import { 3 | checks, 4 | ensures, 5 | requires, 6 | isDefined, 7 | error, 8 | asserts, 9 | unreachable, 10 | } from './index'; 11 | 12 | // CONTRACTS 13 | 14 | function requiresExample(value: string | null) { 15 | requires(isDefined(value)); 16 | expectType(value); 17 | } 18 | 19 | function checksExample(value: string | null) { 20 | checks(isDefined(value)); 21 | expectType(value); 22 | } 23 | 24 | function ensuresExample(value: string | null) { 25 | ensures(isDefined(value)); 26 | expectType(value); 27 | } 28 | 29 | function assertsExample(value: string | null) { 30 | asserts(isDefined(value)); 31 | expectType(value); 32 | } 33 | 34 | // UTILS 35 | 36 | function errorExample(value: string | null) { 37 | const result = value ?? error(); 38 | expectType(result); 39 | } 40 | 41 | interface Named { 42 | name: string; 43 | } 44 | 45 | function isNamed(value: any): value is Named { 46 | return value != null && typeof value.name === 'string'; 47 | } 48 | 49 | function useIfTypeGuardExample(value: any) { 50 | const withName = isNamed(value) ? value : error(); 51 | expectType(withName); 52 | } 53 | 54 | function exhaustiveSwitch(foo: 'a' | 'b'): void { 55 | let x; 56 | switch (foo) { 57 | case 'a': 58 | x = 0; 59 | break; 60 | case 'b': 61 | x = 1; 62 | break; 63 | default: 64 | unreachable(foo); 65 | } 66 | expectType(x); 67 | } 68 | -------------------------------------------------------------------------------- /index.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AssertionError, 3 | asserts, 4 | checks, 5 | checksNonNullish, 6 | ensures, 7 | ensuresNonNullish, 8 | error, 9 | IllegalStateError, 10 | isDefined, 11 | PostconditionError, 12 | PreconditionError, 13 | requires, 14 | requiresNonNullish, 15 | unreachable, 16 | } from './index'; 17 | 18 | const expectError = ( 19 | fun: () => unknown, 20 | errorType: new (...args: any[]) => Error, 21 | message: string 22 | ) => { 23 | try { 24 | fun(); 25 | fail('Function should never succeed'); 26 | } catch (e: any) { 27 | expect(e.name).toBe(errorType.name); 28 | expect(e.message).toBe(message); 29 | } 30 | }; 31 | 32 | describe('contracts', () => { 33 | const contractTest = ( 34 | contract: (condition: boolean, message?: string) => asserts condition, 35 | errorType: new (...args: any[]) => Error, 36 | defaultMessage: string 37 | ) => { 38 | describe(contract.name, () => { 39 | it('should not error if the condition is met', () => { 40 | expect(() => contract(true)).not.toThrowError(); 41 | }); 42 | it('should throw the associated error if the condition is not met', () => { 43 | expectError(() => contract(false), errorType, defaultMessage); 44 | }); 45 | it('should throw the associated error with the given message if the condition is not met', () => { 46 | expectError( 47 | () => contract(false, 'Custom message'), 48 | errorType, 49 | 'Custom message' 50 | ); 51 | }); 52 | }); 53 | }; 54 | 55 | contractTest(requires, PreconditionError, 'Unmet precondition'); 56 | contractTest(checks, IllegalStateError, 'Callee invariant violation'); 57 | contractTest(ensures, PostconditionError, 'Unmet postcondition'); 58 | contractTest(asserts, AssertionError, ''); 59 | }); 60 | 61 | describe('NonNullish contracts', () => { 62 | const contractTest = ( 63 | contract: (value: T, message?: string) => NonNullable, 64 | errorType: new (...args: any[]) => Error 65 | ): void => { 66 | describe(contract.name, () => { 67 | it('should not error if the value is defined', () => { 68 | expect(() => contract('A nice String')).not.toThrowError(); 69 | }); 70 | it('should throw an Error if the value is not defined', () => { 71 | expectError( 72 | () => contract(null), 73 | errorType, 74 | 'Value must not be null or undefined' 75 | ); 76 | }); 77 | }); 78 | }; 79 | 80 | contractTest(requiresNonNullish, PreconditionError); 81 | contractTest(checksNonNullish, IllegalStateError); 82 | contractTest(ensuresNonNullish, PostconditionError); 83 | }); 84 | 85 | describe('error', () => { 86 | it('should always error', () => { 87 | expectError(() => error(), IllegalStateError, ''); 88 | }); 89 | it('should error with the given type', () => { 90 | expectError(() => error(PreconditionError), PreconditionError, ''); 91 | }); 92 | it('should error with the given type and message', () => { 93 | expectError( 94 | () => error(PreconditionError, 'Failed!'), 95 | PreconditionError, 96 | 'Failed!' 97 | ); 98 | }); 99 | it('should error with the given message', () => { 100 | expectError(() => error('Failed!'), IllegalStateError, 'Failed!'); 101 | }); 102 | }); 103 | 104 | describe('isDefined', () => { 105 | it('should return true for defined values', () => { 106 | expect(isDefined('TypeScript')).toBe(true); 107 | expect(isDefined('')).toBe(true); 108 | expect(isDefined(0)).toBe(true); 109 | expect(isDefined(false)).toBe(true); 110 | }); 111 | it('should return false for null-ish values', () => { 112 | expect(isDefined(undefined)).toBe(false); 113 | expect(isDefined(null)).toBe(false); 114 | }); 115 | }); 116 | 117 | describe('unreachable', () => { 118 | it('should always throw an error at runtime', () => { 119 | expectError( 120 | () => unreachable(true as never), 121 | AssertionError, 122 | 'Reached an unreachable case' 123 | ); 124 | }); 125 | it('should always throw an error at runtime with the given message', () => { 126 | expectError( 127 | () => unreachable(true as never, 'Test'), 128 | AssertionError, 129 | 'Test' 130 | ); 131 | }); 132 | it('should not throw an error when the switch is exhaustive', () => { 133 | enum MyEnum { 134 | A, 135 | B, 136 | } 137 | 138 | function myFun(foo: MyEnum): string { 139 | switch (foo) { 140 | case MyEnum.A: 141 | return 'a'; 142 | case MyEnum.B: 143 | return 'b'; 144 | default: 145 | unreachable(foo); 146 | } 147 | } 148 | 149 | expect(() => myFun(MyEnum.A)).not.toThrow(); 150 | expect(() => myFun(MyEnum.B)).not.toThrow(); 151 | }); 152 | }); 153 | 154 | describe('examples', () => { 155 | it('should help with password validation', () => { 156 | // create a "nominal" type and a matching type guard 157 | 158 | /** A nominal type. The `_type` property does not exist at runtime. */ 159 | type Nominal = { _type: T } & D; 160 | /** A string that is a valid email address. */ 161 | type Email = Nominal<'email', string>; 162 | /** A string that is a good password. */ 163 | type Password = Nominal<'password', string>; 164 | 165 | /** Returns `true` if the given value is a valid email address. */ 166 | function isEmail(value: string): value is Email { 167 | return !!value && value.includes('@'); 168 | } 169 | 170 | /** Returns `true` if the given value meets the requirements. */ 171 | function isGoodPassword(value: string): value is Password { 172 | return !!value && value.length >= 8; 173 | } 174 | 175 | // make sure to use the nominal type in later functions 176 | function insert(email: Email, password: Password): void {} 177 | 178 | // the signup endpoint 179 | function signUp(email: string, password: string) { 180 | // use the contracts with your type guards ... 181 | requires(isEmail(email), 'Value must be a valid email address'); 182 | requires(isGoodPassword(password), 'Password must meet requirements'); 183 | // ... to tell the compiler that email and password are in fact of type Email and Password, 184 | // so that you can call the insert function! 185 | insert(email, password); 186 | } 187 | 188 | expect(() => signUp('type@script.com', 'abc12345')).not.toThrow(); 189 | expectError( 190 | () => signUp('typescript.com', 'abc12345'), 191 | PreconditionError, 192 | 'Value must be a valid email address' 193 | ); 194 | expectError( 195 | () => signUp('type@script.com', '1234'), 196 | PreconditionError, 197 | 'Password must meet requirements' 198 | ); 199 | }); 200 | }); 201 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * An error thrown by a code contract. 3 | */ 4 | export abstract class ContractError extends Error {} 5 | 6 | /** 7 | * An error thrown, if a precondition for a function or method is not met. 8 | */ 9 | export class PreconditionError extends ContractError { 10 | constructor(message?: string) { 11 | super(message); 12 | this.name = 'PreconditionError'; 13 | } 14 | } 15 | 16 | /** 17 | * An error thrown, if an object is an illegal state. 18 | */ 19 | export class IllegalStateError extends ContractError { 20 | constructor(message?: string) { 21 | super(message); 22 | this.name = 'IllegalStateError'; 23 | } 24 | } 25 | 26 | /** 27 | * An error thrown, if a function or method could not fulfil a postcondition. 28 | */ 29 | export class PostconditionError extends ContractError { 30 | constructor(message?: string) { 31 | super(message); 32 | this.name = 'PostconditionError'; 33 | } 34 | } 35 | 36 | /** 37 | * An error thrown, if an assertion has failed. 38 | */ 39 | export class AssertionError extends ContractError { 40 | constructor(message?: string) { 41 | super(message); 42 | this.name = 'AssertionError'; 43 | } 44 | } 45 | 46 | /** 47 | * Throws a `PreconditionError` if the `condition` is `false`. 48 | * @param condition the precondition that should be `true` 49 | * @param message an optional message for the error 50 | * @throws PreconditionError if the condition is `false` 51 | * @see PreconditionError 52 | * @example 53 | * function myFun(name: string) { 54 | * requires(name.length > 10, 'Name must be longer than 10 chars'); 55 | * } 56 | */ 57 | export function requires( 58 | condition: boolean, 59 | message = 'Unmet precondition' 60 | ): asserts condition { 61 | if (!condition) { 62 | throw new PreconditionError(message); 63 | } 64 | } 65 | 66 | /** 67 | * Returns the given value unchanged if it is not `null` or `undefined`. 68 | * Throws a `PreconditionError` otherwise. 69 | * @param value the value that should not be `null` or `undefined` 70 | * @param message an optional message for the error 71 | * @throws PreconditionError if the value is `null` or `undefined` 72 | * @see requires 73 | * @example 74 | * function myFun(name: string | null) { 75 | * const nameNonNull = requiresNonNullish(name, 'Name must be defined'); 76 | * nameNonNull.toUpperCase(); // no compiler error! 77 | * } 78 | */ 79 | export function requiresNonNullish( 80 | value: T, 81 | message = 'Value must not be null or undefined' 82 | ): NonNullable { 83 | requires(isDefined(value), message); 84 | return value; 85 | } 86 | 87 | /** 88 | * Throws a `IllegalStateError` if the `condition` is `false`. 89 | * @param condition the condition that should be `true` 90 | * @param message an optional message for the error 91 | * @throws IllegalStateError if the condition is `false` 92 | * @see IllegalStateError 93 | * @example 94 | * class Socket { 95 | * private isOpen = false; 96 | * send(data: Data) { 97 | * check(this.isOpen, 'Socket must be open'); 98 | * } 99 | * open() { 100 | * this.isOpen = true; 101 | * } 102 | * } 103 | */ 104 | export function checks( 105 | condition: boolean, 106 | message = 'Callee invariant violation' 107 | ): asserts condition { 108 | if (!condition) { 109 | throw new IllegalStateError(message); 110 | } 111 | } 112 | 113 | /** 114 | * Returns the given value unchanged if it is not `null` or `undefined`. 115 | * Throws a `IllegalStateError` otherwise. 116 | * @param value the value that should not be `null` or `undefined` 117 | * @param message an optional message for the error 118 | * @throws IllegalStateError if the value is `null` or `undefined` 119 | * @see checks 120 | * @example 121 | * class Socket { 122 | * data: Data | null = null; 123 | * send() { 124 | * const validData = checksNonNullish(this.data, 'Data must be available'); 125 | * validData.send(); // no compiler error! 126 | * } 127 | * } 128 | */ 129 | export function checksNonNullish( 130 | value: T, 131 | message = 'Value must not be null or undefined' 132 | ): NonNullable { 133 | checks(isDefined(value), message); 134 | return value; 135 | } 136 | 137 | /** 138 | * Throws a `PostconditionError` if the `condition` is `false`. 139 | * @param condition the condition that should be `true` 140 | * @param message an optional message for the error 141 | * @throws PostconditionError if the condition is `false` 142 | * @see PostconditionError 143 | * @example 144 | * function myFun() { 145 | * createPerson({ id: 0, name: 'John' }); 146 | * const entity = findById(0); // returns null if not present 147 | * return ensures(isDefined(entity), 'Failed to persist entity'); 148 | * } 149 | */ 150 | export function ensures( 151 | condition: boolean, 152 | message = 'Unmet postcondition' 153 | ): asserts condition { 154 | if (!condition) { 155 | throw new PostconditionError(message); 156 | } 157 | } 158 | 159 | /** 160 | * Returns the given value unchanged if it is not `null` or `undefined`. 161 | * Throws a `PostconditionError` otherwise. 162 | * @param value the value that must not be `null` or `undefined` 163 | * @param message an optional message for the error 164 | * @throws PostconditionError if the value is `null` or `undefined` 165 | * @see ensures 166 | * @example 167 | * function myFun(): Person { 168 | * createPerson({ id: 0, name: 'John' }); 169 | * const entity = findById(0); // returns null if not present 170 | * return ensuresNonNullish(entity, 'Failed to persist entity'); 171 | * } 172 | */ 173 | export function ensuresNonNullish( 174 | value: T, 175 | message = 'Value must not be null or undefined' 176 | ): NonNullable { 177 | ensures(isDefined(value), message); 178 | return value; 179 | } 180 | 181 | /** 182 | * Throws a `AssertionError` if the `condition` is `false`. 183 | * @param condition the condition that must be `true` 184 | * @param message an optional message for the error 185 | * @throws AssertionError if the condition is `false` 186 | * @see AssertionError 187 | */ 188 | export function asserts( 189 | condition: boolean, 190 | message?: string 191 | ): asserts condition { 192 | if (!condition) { 193 | throw new AssertionError(message); 194 | } 195 | } 196 | 197 | /** 198 | * Returns `true` if the value is not `null` or `undefined`. 199 | * @param value the value to test 200 | * @example 201 | * const x: string | null = 'Hello'; 202 | * if (isDefined(x)) { 203 | * x.toLowerCase(); // no compiler error! 204 | * } 205 | */ 206 | export function isDefined(value: T): value is NonNullable { 207 | return value != null; 208 | } 209 | 210 | /* eslint-disable @typescript-eslint/no-explicit-any, new-cap */ 211 | 212 | /** 213 | * Always throws an `IllegalStateError` with the given message. 214 | * @param message the message for the `IllegalStateError` 215 | * @throws IllegalStateError in any case 216 | * @see IllegalStateError 217 | * @example 218 | * function myFun(foo: string | null) { 219 | * const bar = foo ?? error(PreconditionError, 'Argument may not be null'); 220 | * const result = bar.length > 0 ? 'OK' : error('Something went wrong!'); 221 | * } 222 | */ 223 | export function error(message?: string): never; 224 | 225 | /** 226 | * Always throws an error of the given type with the given message. 227 | * @param errorType an error class 228 | * @param message the error message 229 | * @throws errorType in any case 230 | * @see IllegalStateError 231 | * @example 232 | * function myFun(foo: string | null) { 233 | * const bar = foo ?? error(PreconditionError, 'Argument may not be null'); 234 | * const result = bar.length > 0 ? 'OK' : error('Something went wrong!'); 235 | * } 236 | */ 237 | export function error( 238 | errorType: new (...args: any[]) => Error, 239 | message?: string 240 | ): never; 241 | 242 | export function error( 243 | errorType?: string | (new (...args: any[]) => Error), 244 | message?: string 245 | ): never { 246 | throw errorType == null || typeof errorType === 'string' 247 | ? new IllegalStateError(errorType) 248 | : new errorType(message); 249 | } 250 | 251 | /* eslint-enable @typescript-eslint/no-explicit-any, new-cap */ 252 | 253 | /* eslint-disable @typescript-eslint/no-unused-vars */ 254 | 255 | /** 256 | * Asserts that a code branch is unreachable. If it is, the compiler will throw a type error. 257 | * If this function is reached at runtime, an error will be thrown. 258 | * @param value a value 259 | * @param message an optional message for the error 260 | * @throws AssertionError in any case 261 | * @example 262 | * function myFun(foo: MyEnum): string { 263 | * switch(foo) { 264 | * case MyEnum.A: return 'a'; 265 | * case MyEnum.B: return 'b'; 266 | * // no compiler error if MyEnum only has A and B 267 | * default: unreachable(foo); 268 | * } 269 | * } 270 | */ 271 | export function unreachable( 272 | value: never, 273 | message = 'Reached an unreachable case' 274 | ): never { 275 | throw new AssertionError(message); 276 | } 277 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@jest/types').Config.InitialOptions} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | testRegex: 'index\\.test\\.ts', 6 | globals: { 7 | 'ts-jest': { 8 | tsconfig: 'tsconfig.test.json', 9 | }, 10 | }, 11 | collectCoverage: true, 12 | coverageThreshold: { 13 | global: { 14 | branches: 100, 15 | functions: 100, 16 | lines: 100, 17 | statements: 100, 18 | }, 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ts-code-contracts", 3 | "version": "1.0.0", 4 | "description": "Design by contract with TypeScript.", 5 | "main": "index.js", 6 | "types": "index.d.ts", 7 | "scripts": { 8 | "// TESTS": "tsd needs the index.d.ts file from the build but index.js breaks the test coverage for jest", 9 | "pretest": "npm run build && rimraf index.js", 10 | "test": "jest && tsd", 11 | "build": "tsc -p tsconfig.json", 12 | "lint": "eslint --fix index.ts", 13 | "prettier": "npx prettier --write **/*.{ts,html,scss,json,js,json,md,yaml} --ignore-path .gitignore", 14 | "release": "npx standard-version", 15 | "prepare": "husky install" 16 | }, 17 | "files": [ 18 | "CHANGELOG.md", 19 | "index.d.ts" 20 | ], 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/JanMalch/ts-code-contracts.git" 24 | }, 25 | "keywords": [ 26 | "Contracts", 27 | "Code Contracts", 28 | "Code Quality", 29 | "TypeScript" 30 | ], 31 | "author": "JanMalch", 32 | "license": "MIT", 33 | "bugs": { 34 | "url": "https://github.com/JanMalch/ts-code-contracts/issues" 35 | }, 36 | "homepage": "https://github.com/JanMalch/ts-code-contracts#readme", 37 | "devDependencies": { 38 | "@commitlint/cli": "^18.4.4", 39 | "@commitlint/config-conventional": "^18.4.4", 40 | "@types/jest": "^29.5.11", 41 | "@typescript-eslint/eslint-plugin": "^6.19.0", 42 | "@typescript-eslint/parser": "^6.19.0", 43 | "eslint": "^8.56.0", 44 | "eslint-config-prettier": "^9.1.0", 45 | "eslint-plugin-import": "^2.29.1", 46 | "eslint-plugin-prettier": "^5.1.3", 47 | "husky": "^8.0.3", 48 | "jest": "^29.7.0", 49 | "lint-staged": "^15.2.0", 50 | "prettier": "^3.2.4", 51 | "rimraf": "^5.0.5", 52 | "standard-version": "^9.5.0", 53 | "ts-jest": "^29.1.1", 54 | "tsd": "^0.30.4", 55 | "typescript": "^5.3.3" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 8 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | "declaration": true /* Generates corresponding '.d.ts' file. */, 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | // "outDir": "./", /* Redirect output structure to the directory. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true /* Enable all strict type-checking options. */, 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolution Options */ 44 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 45 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 46 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 47 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 48 | // "typeRoots": [], /* List of folders to include type definitions from. */ 49 | // "types": [], /* Type declaration files to be included in compilation. */ 50 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 51 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 52 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 53 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 54 | 55 | /* Source Map Options */ 56 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 59 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 60 | 61 | /* Experimental Options */ 62 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 63 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 64 | 65 | /* Advanced Options */ 66 | "skipLibCheck": true /* Skip type checking of declaration files. */, 67 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 68 | }, 69 | "files": ["index.ts"] 70 | } 71 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "inlineSourceMap": true, 5 | "target": "ES6" 6 | } 7 | } 8 | --------------------------------------------------------------------------------