├── .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 |
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 |
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 |
--------------------------------------------------------------------------------