├── .github └── workflows │ └── release.yml ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── src └── index.ts ├── test ├── index.test.ts └── tsconfig.json └── tsconfig.json /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: [push, pull_request] 3 | 4 | concurrency: 5 | group: ${{ github.workflow }}-${{ github.ref }} 6 | cancel-in-progress: true 7 | 8 | permissions: 9 | contents: write # to be able to publish a GitHub release 10 | id-token: write # to enable use of OIDC for npm provenance 11 | issues: write # to be able to comment on released issues 12 | pull-requests: write # to be able to comment on released pull requests 13 | 14 | jobs: 15 | test: 16 | name: 🧪 Test 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: ⬇️ Checkout repo 20 | uses: actions/checkout@v4 21 | 22 | - name: ⎔ Setup node 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: 20 26 | 27 | - name: 📥 Download deps 28 | uses: bahmutov/npm-install@v1 29 | with: 30 | useLockFile: false 31 | 32 | - name: 🧪 Test 33 | run: npm run test 34 | 35 | release: 36 | name: 🚀 Release 37 | needs: [test] 38 | runs-on: ubuntu-latest 39 | if: 40 | ${{ github.repository == 'epicweb-dev/invariant' && 41 | contains('refs/heads/main,refs/heads/beta,refs/heads/next,refs/heads/alpha', 42 | github.ref) && github.event_name == 'push' }} 43 | steps: 44 | - name: ⬇️ Checkout repo 45 | uses: actions/checkout@v4 46 | 47 | - name: ⎔ Setup node 48 | uses: actions/setup-node@v4 49 | with: 50 | node-version: 20 51 | 52 | - name: 📥 Download deps 53 | uses: bahmutov/npm-install@v1 54 | with: 55 | useLockFile: false 56 | 57 | - name: 📦 Run Build 58 | run: npm run build 59 | 60 | - name: 🚀 Release 61 | uses: cycjimmy/semantic-release-action@v4 62 | with: 63 | semantic_version: 17 64 | branches: | 65 | [ 66 | '+([0-9])?(.{+([0-9]),x}).x', 67 | 'main', 68 | 'next', 69 | 'next-major', 70 | {name: 'beta', prerelease: true}, 71 | {name: 'alpha', prerelease: true} 72 | ] 73 | env: 74 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 75 | NPM_CONFIG_PROVENANCE: true 76 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 77 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | tsconfig.tsbuildinfo 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

💥 @epic-web/invariant

3 | 4 | Throw errors when thing's aren't right. 5 | 6 |

7 | Type safe utilities for throwing errors (and responses) in exceptional 8 | situations in a declarative way. 9 |

10 |
11 | 12 | ``` 13 | npm install @epic-web/invariant 14 | ``` 15 | 16 |
17 | 21 | 25 | 26 |
27 | 28 |
29 | 30 | 31 | [![Build Status][build-badge]][build] 32 | [![MIT License][license-badge]][license] 33 | [![Code of Conduct][coc-badge]][coc] 34 | 35 | 36 | ## The Problem 37 | 38 | Your application has boundaries. Network requests/responses, file system reads, 39 | etc. When you're working with these boundaries, you need to be able to handle 40 | errors that may occur, even in TypeScript. 41 | 42 | TypeScript will typically make these boundaries much more obvious because it 43 | doesn't like not knowing what the type of something is. For example: 44 | 45 | ```ts 46 | const formData = new FormData(formElement) 47 | const name = await formData.get('name') 48 | // name is `File | string | null` 49 | ``` 50 | 51 | Often it's a good idea to use a proper parsing library for situations like this, 52 | but for simple cases that can often feel like overkill. But you don't want to 53 | just ignore TypeScript because: 54 | 55 | > TypeScript is that brutally honest friend you put up with because they save 56 | > you from making terrible mistakes. – 57 | > [@kentcdodds](https://twitter.com/kentcdodds/status/1715562350835855396) 58 | 59 | So you check it: 60 | 61 | ```ts 62 | const formData = new FormData(formElement) 63 | const name = await formData.get('name') 64 | // name is `File | string | null` 65 | if (typeof name !== 'string') { 66 | throw new Error('Name must be a string') 67 | } 68 | // now name is `string` (and TypeScript knows it too) 69 | ``` 70 | 71 | You're fine throwing a descriptive error here because it's just _very_ unlikely 72 | this will ever happen and even if it does you wouldn't really know what to do 73 | about it anyway. 74 | 75 | It's not a big deal, but there's a tiny bit of boilerplate that would be nice to 76 | avoid. Especially when you find yourself doing this all over the codebase. This 77 | is the problem `@epic-web/invariant` solves. 78 | 79 | ## The Solution 80 | 81 | Here's the diff from what we had above: 82 | 83 | ```diff 84 | const formData = new FormData(formElement) 85 | const name = await formData.get('name') 86 | // name is `File | string | null` 87 | - if (typeof name !== 'string') { 88 | - throw new Error('Name must be a string') 89 | - } 90 | + invariant(typeof name === 'string', 'Name must be a string') 91 | // now name is `string` (and TypeScript knows it too) 92 | ``` 93 | 94 | It's pretty simple. But honestly, it's nicer to read, it throws a special 95 | `InvariantError` object to distinguish it from other types of errors, and we 96 | have another useful utility for throwing `Response` objects instead of `Error` 97 | objects which is handy 98 | [in Remix](https://remix.run/docs/en/main/route/loader#throwing-responses-in-loaders). 99 | 100 | ## Usage 101 | 102 | ### `invariant` 103 | 104 | The `invariant` function is used to assert that a condition is true. If the 105 | condition is false, it throws an error with the provided message. 106 | 107 | **Basic Usage** 108 | 109 | ```ts 110 | import { invariant } from '@epic-web/invariant' 111 | 112 | const creature = { name: 'Dragon', type: 'Fire' } 113 | invariant(creature.name === 'Dragon', 'Creature must be a Dragon') 114 | ``` 115 | 116 | **Throwing an Error on False Condition** 117 | 118 | ```ts 119 | import { invariant } from '@epic-web/invariant' 120 | 121 | const creature = { name: 'Unicorn', type: 'Magic' } 122 | invariant(creature.type === 'Fire', 'Creature must be of type Fire') 123 | // Throws: InvariantError: Creature must be of type Fire 124 | ``` 125 | 126 | **Using Callback for Error Message** 127 | 128 | ```ts 129 | import { invariant } from '@epic-web/invariant' 130 | 131 | const creature = { name: 'Elf', type: 'Forest' } 132 | invariant(creature.type === 'Water', () => 'Creature must be of type Water') 133 | // Throws: InvariantError: Creature must be of type Water 134 | ``` 135 | 136 | ### `invariantResponse` 137 | 138 | The `invariantResponse` function works similarly to `invariant`, but instead of 139 | throwing an `InvariantError`, it throws a Response object. 140 | 141 | **Basic Usage** 142 | 143 | ```ts 144 | import { invariantResponse } from '@epic-web/invariant' 145 | 146 | const creature = { name: 'Phoenix', type: 'Fire' } 147 | invariantResponse(creature.type === 'Fire', 'Creature must be of type Fire') 148 | ``` 149 | 150 | **Throwing a Response on False Condition** 151 | 152 | ```ts 153 | import { invariantResponse } from '@epic-web/invariant' 154 | 155 | const creature = { name: 'Griffin', type: 'Air' } 156 | invariantResponse(creature.type === 'Water', 'Creature must be of type Water') 157 | // Throws: Response { status: 400, body: 'Creature must be of type Water' } 158 | ``` 159 | 160 | The response status default if 400 (Bad Request), but you'll find how to change 161 | that below. 162 | 163 | **Using Callback for Response Message** 164 | 165 | ```ts 166 | import { invariantResponse } from '@epic-web/invariant' 167 | 168 | const creature = { name: 'Mermaid', type: 'Water' } 169 | invariantResponse( 170 | creature.type === 'Land', 171 | () => `Expected a Land creature, but got a ${creature.type} creature`, 172 | ) 173 | ``` 174 | 175 | **Throwing a Response with Additional Options** 176 | 177 | ```ts 178 | import { invariantResponse } from '@epic-web/invariant' 179 | 180 | const creature = { name: 'Cerberus', type: 'Underworld' } 181 | invariantResponse( 182 | creature.type === 'Sky', 183 | JSON.stringify({ error: 'Creature must be of type Sky' }), 184 | { status: 500, headers: { 'Content-Type': 'application/json' } }, 185 | ) 186 | ``` 187 | 188 | ## Differences from [invariant](https://www.npmjs.com/package/invariant) 189 | 190 | There are three main differences. With `@epic-web/invariant`: 191 | 192 | 1. Error messages are the same in dev and prod 193 | 2. It's typesafe 194 | 3. We support the common case (for Remix anyway) of throwing Responses as well 195 | with `invariantResponse`. 196 | 197 | ## License 198 | 199 | MIT 200 | 201 | 202 | [build-badge]: https://img.shields.io/github/actions/workflow/status/epicweb-dev/invariant/release.yml?branch=main&logo=github&style=flat-square 203 | [build]: https://github.com/epicweb-dev/invariant/actions?query=workflow%3Arelease 204 | [license-badge]: https://img.shields.io/badge/license-MIT%20License-blue.svg?style=flat-square 205 | [license]: https://github.com/epicweb-dev/invariant/blob/main/LICENSE 206 | [coc-badge]: https://img.shields.io/badge/code%20of-conduct-ff69b4.svg?style=flat-square 207 | [coc]: https://kentcdodds.com/conduct 208 | 209 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@epic-web/invariant", 3 | "version": "0.0.0-semantically-released", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "@epic-web/invariant", 9 | "version": "0.0.0-semantically-released", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "@types/node": "^20.10.4", 13 | "prettier": "^3.1.1", 14 | "tsx": "^4.6.2", 15 | "typescript": "^5.3.3" 16 | } 17 | }, 18 | "node_modules/@esbuild/android-arm": { 19 | "version": "0.18.20", 20 | "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", 21 | "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", 22 | "cpu": [ 23 | "arm" 24 | ], 25 | "dev": true, 26 | "optional": true, 27 | "os": [ 28 | "android" 29 | ], 30 | "engines": { 31 | "node": ">=12" 32 | } 33 | }, 34 | "node_modules/@esbuild/android-arm64": { 35 | "version": "0.18.20", 36 | "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", 37 | "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", 38 | "cpu": [ 39 | "arm64" 40 | ], 41 | "dev": true, 42 | "optional": true, 43 | "os": [ 44 | "android" 45 | ], 46 | "engines": { 47 | "node": ">=12" 48 | } 49 | }, 50 | "node_modules/@esbuild/android-x64": { 51 | "version": "0.18.20", 52 | "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", 53 | "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", 54 | "cpu": [ 55 | "x64" 56 | ], 57 | "dev": true, 58 | "optional": true, 59 | "os": [ 60 | "android" 61 | ], 62 | "engines": { 63 | "node": ">=12" 64 | } 65 | }, 66 | "node_modules/@esbuild/darwin-arm64": { 67 | "version": "0.18.20", 68 | "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", 69 | "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", 70 | "cpu": [ 71 | "arm64" 72 | ], 73 | "dev": true, 74 | "optional": true, 75 | "os": [ 76 | "darwin" 77 | ], 78 | "engines": { 79 | "node": ">=12" 80 | } 81 | }, 82 | "node_modules/@esbuild/darwin-x64": { 83 | "version": "0.18.20", 84 | "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", 85 | "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", 86 | "cpu": [ 87 | "x64" 88 | ], 89 | "dev": true, 90 | "optional": true, 91 | "os": [ 92 | "darwin" 93 | ], 94 | "engines": { 95 | "node": ">=12" 96 | } 97 | }, 98 | "node_modules/@esbuild/freebsd-arm64": { 99 | "version": "0.18.20", 100 | "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", 101 | "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", 102 | "cpu": [ 103 | "arm64" 104 | ], 105 | "dev": true, 106 | "optional": true, 107 | "os": [ 108 | "freebsd" 109 | ], 110 | "engines": { 111 | "node": ">=12" 112 | } 113 | }, 114 | "node_modules/@esbuild/freebsd-x64": { 115 | "version": "0.18.20", 116 | "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", 117 | "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", 118 | "cpu": [ 119 | "x64" 120 | ], 121 | "dev": true, 122 | "optional": true, 123 | "os": [ 124 | "freebsd" 125 | ], 126 | "engines": { 127 | "node": ">=12" 128 | } 129 | }, 130 | "node_modules/@esbuild/linux-arm": { 131 | "version": "0.18.20", 132 | "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", 133 | "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", 134 | "cpu": [ 135 | "arm" 136 | ], 137 | "dev": true, 138 | "optional": true, 139 | "os": [ 140 | "linux" 141 | ], 142 | "engines": { 143 | "node": ">=12" 144 | } 145 | }, 146 | "node_modules/@esbuild/linux-arm64": { 147 | "version": "0.18.20", 148 | "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", 149 | "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", 150 | "cpu": [ 151 | "arm64" 152 | ], 153 | "dev": true, 154 | "optional": true, 155 | "os": [ 156 | "linux" 157 | ], 158 | "engines": { 159 | "node": ">=12" 160 | } 161 | }, 162 | "node_modules/@esbuild/linux-ia32": { 163 | "version": "0.18.20", 164 | "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", 165 | "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", 166 | "cpu": [ 167 | "ia32" 168 | ], 169 | "dev": true, 170 | "optional": true, 171 | "os": [ 172 | "linux" 173 | ], 174 | "engines": { 175 | "node": ">=12" 176 | } 177 | }, 178 | "node_modules/@esbuild/linux-loong64": { 179 | "version": "0.18.20", 180 | "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", 181 | "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", 182 | "cpu": [ 183 | "loong64" 184 | ], 185 | "dev": true, 186 | "optional": true, 187 | "os": [ 188 | "linux" 189 | ], 190 | "engines": { 191 | "node": ">=12" 192 | } 193 | }, 194 | "node_modules/@esbuild/linux-mips64el": { 195 | "version": "0.18.20", 196 | "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", 197 | "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", 198 | "cpu": [ 199 | "mips64el" 200 | ], 201 | "dev": true, 202 | "optional": true, 203 | "os": [ 204 | "linux" 205 | ], 206 | "engines": { 207 | "node": ">=12" 208 | } 209 | }, 210 | "node_modules/@esbuild/linux-ppc64": { 211 | "version": "0.18.20", 212 | "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", 213 | "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", 214 | "cpu": [ 215 | "ppc64" 216 | ], 217 | "dev": true, 218 | "optional": true, 219 | "os": [ 220 | "linux" 221 | ], 222 | "engines": { 223 | "node": ">=12" 224 | } 225 | }, 226 | "node_modules/@esbuild/linux-riscv64": { 227 | "version": "0.18.20", 228 | "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", 229 | "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", 230 | "cpu": [ 231 | "riscv64" 232 | ], 233 | "dev": true, 234 | "optional": true, 235 | "os": [ 236 | "linux" 237 | ], 238 | "engines": { 239 | "node": ">=12" 240 | } 241 | }, 242 | "node_modules/@esbuild/linux-s390x": { 243 | "version": "0.18.20", 244 | "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", 245 | "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", 246 | "cpu": [ 247 | "s390x" 248 | ], 249 | "dev": true, 250 | "optional": true, 251 | "os": [ 252 | "linux" 253 | ], 254 | "engines": { 255 | "node": ">=12" 256 | } 257 | }, 258 | "node_modules/@esbuild/linux-x64": { 259 | "version": "0.18.20", 260 | "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", 261 | "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", 262 | "cpu": [ 263 | "x64" 264 | ], 265 | "dev": true, 266 | "optional": true, 267 | "os": [ 268 | "linux" 269 | ], 270 | "engines": { 271 | "node": ">=12" 272 | } 273 | }, 274 | "node_modules/@esbuild/netbsd-x64": { 275 | "version": "0.18.20", 276 | "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", 277 | "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", 278 | "cpu": [ 279 | "x64" 280 | ], 281 | "dev": true, 282 | "optional": true, 283 | "os": [ 284 | "netbsd" 285 | ], 286 | "engines": { 287 | "node": ">=12" 288 | } 289 | }, 290 | "node_modules/@esbuild/openbsd-x64": { 291 | "version": "0.18.20", 292 | "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", 293 | "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", 294 | "cpu": [ 295 | "x64" 296 | ], 297 | "dev": true, 298 | "optional": true, 299 | "os": [ 300 | "openbsd" 301 | ], 302 | "engines": { 303 | "node": ">=12" 304 | } 305 | }, 306 | "node_modules/@esbuild/sunos-x64": { 307 | "version": "0.18.20", 308 | "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", 309 | "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", 310 | "cpu": [ 311 | "x64" 312 | ], 313 | "dev": true, 314 | "optional": true, 315 | "os": [ 316 | "sunos" 317 | ], 318 | "engines": { 319 | "node": ">=12" 320 | } 321 | }, 322 | "node_modules/@esbuild/win32-arm64": { 323 | "version": "0.18.20", 324 | "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", 325 | "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", 326 | "cpu": [ 327 | "arm64" 328 | ], 329 | "dev": true, 330 | "optional": true, 331 | "os": [ 332 | "win32" 333 | ], 334 | "engines": { 335 | "node": ">=12" 336 | } 337 | }, 338 | "node_modules/@esbuild/win32-ia32": { 339 | "version": "0.18.20", 340 | "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", 341 | "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", 342 | "cpu": [ 343 | "ia32" 344 | ], 345 | "dev": true, 346 | "optional": true, 347 | "os": [ 348 | "win32" 349 | ], 350 | "engines": { 351 | "node": ">=12" 352 | } 353 | }, 354 | "node_modules/@esbuild/win32-x64": { 355 | "version": "0.18.20", 356 | "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", 357 | "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", 358 | "cpu": [ 359 | "x64" 360 | ], 361 | "dev": true, 362 | "optional": true, 363 | "os": [ 364 | "win32" 365 | ], 366 | "engines": { 367 | "node": ">=12" 368 | } 369 | }, 370 | "node_modules/@types/node": { 371 | "version": "20.10.4", 372 | "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.4.tgz", 373 | "integrity": "sha512-D08YG6rr8X90YB56tSIuBaddy/UXAA9RKJoFvrsnogAum/0pmjkgi4+2nx96A330FmioegBWmEYQ+syqCFaveg==", 374 | "dev": true, 375 | "dependencies": { 376 | "undici-types": "~5.26.4" 377 | } 378 | }, 379 | "node_modules/esbuild": { 380 | "version": "0.18.20", 381 | "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", 382 | "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", 383 | "dev": true, 384 | "hasInstallScript": true, 385 | "bin": { 386 | "esbuild": "bin/esbuild" 387 | }, 388 | "engines": { 389 | "node": ">=12" 390 | }, 391 | "optionalDependencies": { 392 | "@esbuild/android-arm": "0.18.20", 393 | "@esbuild/android-arm64": "0.18.20", 394 | "@esbuild/android-x64": "0.18.20", 395 | "@esbuild/darwin-arm64": "0.18.20", 396 | "@esbuild/darwin-x64": "0.18.20", 397 | "@esbuild/freebsd-arm64": "0.18.20", 398 | "@esbuild/freebsd-x64": "0.18.20", 399 | "@esbuild/linux-arm": "0.18.20", 400 | "@esbuild/linux-arm64": "0.18.20", 401 | "@esbuild/linux-ia32": "0.18.20", 402 | "@esbuild/linux-loong64": "0.18.20", 403 | "@esbuild/linux-mips64el": "0.18.20", 404 | "@esbuild/linux-ppc64": "0.18.20", 405 | "@esbuild/linux-riscv64": "0.18.20", 406 | "@esbuild/linux-s390x": "0.18.20", 407 | "@esbuild/linux-x64": "0.18.20", 408 | "@esbuild/netbsd-x64": "0.18.20", 409 | "@esbuild/openbsd-x64": "0.18.20", 410 | "@esbuild/sunos-x64": "0.18.20", 411 | "@esbuild/win32-arm64": "0.18.20", 412 | "@esbuild/win32-ia32": "0.18.20", 413 | "@esbuild/win32-x64": "0.18.20" 414 | } 415 | }, 416 | "node_modules/fsevents": { 417 | "version": "2.3.3", 418 | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", 419 | "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", 420 | "dev": true, 421 | "hasInstallScript": true, 422 | "optional": true, 423 | "os": [ 424 | "darwin" 425 | ], 426 | "engines": { 427 | "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 428 | } 429 | }, 430 | "node_modules/get-tsconfig": { 431 | "version": "4.7.2", 432 | "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.2.tgz", 433 | "integrity": "sha512-wuMsz4leaj5hbGgg4IvDU0bqJagpftG5l5cXIAvo8uZrqn0NJqwtfupTN00VnkQJPcIRrxYrm1Ue24btpCha2A==", 434 | "dev": true, 435 | "dependencies": { 436 | "resolve-pkg-maps": "^1.0.0" 437 | }, 438 | "funding": { 439 | "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" 440 | } 441 | }, 442 | "node_modules/prettier": { 443 | "version": "3.1.1", 444 | "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.1.tgz", 445 | "integrity": "sha512-22UbSzg8luF4UuZtzgiUOfcGM8s4tjBv6dJRT7j275NXsy2jb4aJa4NNveul5x4eqlF1wuhuR2RElK71RvmVaw==", 446 | "dev": true, 447 | "bin": { 448 | "prettier": "bin/prettier.cjs" 449 | }, 450 | "engines": { 451 | "node": ">=14" 452 | }, 453 | "funding": { 454 | "url": "https://github.com/prettier/prettier?sponsor=1" 455 | } 456 | }, 457 | "node_modules/resolve-pkg-maps": { 458 | "version": "1.0.0", 459 | "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", 460 | "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", 461 | "dev": true, 462 | "funding": { 463 | "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" 464 | } 465 | }, 466 | "node_modules/tsx": { 467 | "version": "4.6.2", 468 | "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.6.2.tgz", 469 | "integrity": "sha512-QPpBdJo+ZDtqZgAnq86iY/PD2KYCUPSUGIunHdGwyII99GKH+f3z3FZ8XNFLSGQIA4I365ui8wnQpl8OKLqcsg==", 470 | "dev": true, 471 | "dependencies": { 472 | "esbuild": "~0.18.20", 473 | "get-tsconfig": "^4.7.2" 474 | }, 475 | "bin": { 476 | "tsx": "dist/cli.mjs" 477 | }, 478 | "engines": { 479 | "node": ">=18.0.0" 480 | }, 481 | "optionalDependencies": { 482 | "fsevents": "~2.3.3" 483 | } 484 | }, 485 | "node_modules/typescript": { 486 | "version": "5.3.3", 487 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", 488 | "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", 489 | "dev": true, 490 | "bin": { 491 | "tsc": "bin/tsc", 492 | "tsserver": "bin/tsserver" 493 | }, 494 | "engines": { 495 | "node": ">=14.17" 496 | } 497 | }, 498 | "node_modules/undici-types": { 499 | "version": "5.26.5", 500 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", 501 | "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", 502 | "dev": true 503 | } 504 | } 505 | } 506 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@epic-web/invariant", 3 | "version": "0.0.0-semantically-released", 4 | "description": "Type safe utilities for throwing errors (and responses) if things aren't quite right. Inspired by npm.im/invariant", 5 | "publishConfig": { 6 | "access": "public" 7 | }, 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/epicweb-dev/invariant" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/epicweb-dev/invariant/issues" 14 | }, 15 | "homepage": "https://github.com/epicweb-dev/invariant#readme", 16 | "type": "module", 17 | "main": "./dist/index.js", 18 | "module": "./dist/index.js", 19 | "exports": { 20 | ".": { 21 | "types": "./dist/index.d.ts", 22 | "default": "./dist/index.js" 23 | } 24 | }, 25 | "files": [ 26 | "dist" 27 | ], 28 | "scripts": { 29 | "build": "tsc", 30 | "format": "prettier --write .", 31 | "test": "tsx --test --test-reporter spec --experimental-test-coverage test/*.test.ts", 32 | "test:watch": "tsx --test --test-reporter spec --watch test/*.test.ts" 33 | }, 34 | "devDependencies": { 35 | "@types/node": "^20.10.4", 36 | "prettier": "^3.1.1", 37 | "tsx": "^4.6.2", 38 | "typescript": "^5.3.3" 39 | }, 40 | "prettier": { 41 | "semi": false, 42 | "useTabs": true, 43 | "singleQuote": true, 44 | "proseWrap": "always", 45 | "overrides": [ 46 | { 47 | "files": [ 48 | "**/*.json" 49 | ], 50 | "options": { 51 | "useTabs": false 52 | } 53 | } 54 | ] 55 | }, 56 | "keywords": [], 57 | "author": "Kent C. Dodds (https://kentcdodds.com/)", 58 | "license": "MIT" 59 | } 60 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export class InvariantError extends Error { 2 | constructor(message: string) { 3 | super(message) 4 | Object.setPrototypeOf(this, InvariantError.prototype) 5 | } 6 | } 7 | /** 8 | * Provide a condition and if that condition is falsey, this throws an error 9 | * with the given message. 10 | * 11 | * inspired by invariant from 'tiny-invariant' except will still include the 12 | * message in production. 13 | * 14 | * @example 15 | * invariant(typeof value === 'string', `value must be a string`) 16 | * 17 | * @param condition The condition to check 18 | * @param message The message to throw (or a callback to generate the message) 19 | * 20 | * @throws {InvariantError} if condition is falsey 21 | */ 22 | export function invariant( 23 | condition: unknown, 24 | message: string | (() => string), 25 | ): asserts condition { 26 | if (!condition) { 27 | throw new InvariantError( 28 | typeof message === 'function' ? message() : message, 29 | ) 30 | } 31 | } 32 | 33 | /** 34 | * Provide a condition and if that condition is falsey, this throws a 400 35 | * Response with the given message. 36 | * 37 | * inspired by invariant from 'tiny-invariant' 38 | * 39 | * @example 40 | * invariantResponse(typeof value === 'string', `value must be a string`) 41 | * 42 | * @param condition The condition to check 43 | * @param message The message to throw (or a callback to generate the message) 44 | * @param responseInit Additional response init options if a response is thrown 45 | * 46 | * @throws {Response} if condition is falsey 47 | */ 48 | export function invariantResponse( 49 | condition: unknown, 50 | message: string | (() => string), 51 | responseInit?: ResponseInit, 52 | ): asserts condition { 53 | if (!condition) { 54 | throw new Response(typeof message === 'function' ? message() : message, { 55 | status: 400, 56 | ...responseInit, 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'node:test' 2 | import assert from 'node:assert' 3 | import { invariant, invariantResponse } from '../src' 4 | 5 | test('invariant should not throw an error for a true condition', () => { 6 | const creature = { name: 'Dragon', type: 'Fire' } 7 | assert.doesNotThrow(() => { 8 | invariant(creature.name === 'Dragon', 'Creature must be a Dragon') 9 | }) 10 | }) 11 | 12 | test('invariant should throw an error for a false condition', () => { 13 | const creature = { name: 'Unicorn', type: 'Magic' } 14 | assert.throws(() => { 15 | invariant(creature.type === 'Fire', 'Creature must be of type Fire') 16 | }, /Creature must be of type Fire/) 17 | }) 18 | 19 | test('invariant message can be a callback as an optimization', () => { 20 | const creature = { name: 'Elf', type: 'Forest' } 21 | assert.throws(() => { 22 | invariant(creature.type === 'Water', () => 'Creature must be of type Water') 23 | }, /Creature must be of type Water/) 24 | }) 25 | 26 | test('invariantResponse should not throw a Response for a true condition', () => { 27 | const creature = { name: 'Phoenix', type: 'Fire' } 28 | assert.doesNotThrow(() => { 29 | invariantResponse(creature.type === 'Fire', 'Creature must be of type Fire') 30 | }) 31 | }) 32 | 33 | test('invariantResponse should throw a Response for a false condition', async () => { 34 | const creature = { name: 'Griffin', type: 'Air' } 35 | try { 36 | invariantResponse( 37 | creature.type === 'Water', 38 | 'Creature must be of type Water', 39 | ) 40 | assert.fail('Expected to throw a Response') 41 | } catch (error: unknown) { 42 | invariant(error instanceof Response, 'Expected to throw a Response') 43 | assert.strictEqual(error.status, 400) 44 | assert.strictEqual(await error.text(), 'Creature must be of type Water') 45 | } 46 | }) 47 | 48 | test('invariantResponse message can be a callback as an optimization', async () => { 49 | const creature = { name: 'Mermaid', type: 'Water' } 50 | try { 51 | invariantResponse( 52 | creature.type === 'Land', 53 | () => `Expected a Land creature, but got a ${creature.type} creature`, 54 | ) 55 | assert.fail('Expected to throw a Response') 56 | } catch (error: unknown) { 57 | invariant(error instanceof Response, 'Expected to throw a Response') 58 | assert.strictEqual(error.status, 400) 59 | assert.strictEqual( 60 | await error.text(), 61 | `Expected a Land creature, but got a ${creature.type} creature`, 62 | ) 63 | } 64 | }) 65 | 66 | test('invariantResponse should throw a Response with additional responseInit options for a false condition', async () => { 67 | const creature = { name: 'Cerberus', type: 'Underworld' } 68 | try { 69 | invariantResponse( 70 | creature.type === 'Sky', 71 | JSON.stringify({ error: 'Creature must be of type Sky' }), 72 | { status: 500, headers: { 'Content-Type': 'text/json' } }, 73 | ) 74 | assert.fail( 75 | 'Expected to throw a Response with additional responseInit options', 76 | ) 77 | } catch (error: unknown) { 78 | invariant(error instanceof Response, 'Expected to throw a Response') 79 | assert.strictEqual(error.status, 500) 80 | assert.deepStrictEqual(await error.json(), { 81 | error: 'Creature must be of type Sky', 82 | }) 83 | assert.strictEqual(error.headers.get('Content-Type'), 'text/json') 84 | } 85 | }) 86 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["../src/**/*", "../test/**/*"], 4 | "compilerOptions": { 5 | "rootDir": "..", 6 | "noEmit": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./src/**/*.ts"], 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "lib": ["ESNext", "DOM"], 6 | "module": "ES2022", 7 | "target": "ES2022", 8 | "moduleResolution": "bundler", 9 | "moduleDetection": "force", 10 | "composite": true, 11 | "strict": true, 12 | "downlevelIteration": true, 13 | "skipLibCheck": true, 14 | "allowSyntheticDefaultImports": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "outDir": "dist", 17 | "declarationDir": "dist" 18 | } 19 | } 20 | --------------------------------------------------------------------------------