celery-env 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  </p>
6
6
 
7
7
  <p align="center">
8
- <strong>Environment validation that compiles to tiny standalone JavaScript.</strong>
8
+ <strong>Type-safe process.env validation that compiles to tiny standalone JavaScript.</strong>
9
9
  </p>
10
10
 
11
11
  <p align="center">
@@ -22,10 +22,18 @@ messages, and fast startup.
22
22
 
23
23
  ## Install
24
24
 
25
+ Generated mode:
26
+
25
27
  ```sh
26
28
  npm install -D celery-env
27
29
  ```
28
30
 
31
+ Runtime mode:
32
+
33
+ ```sh
34
+ npm install celery-env
35
+ ```
36
+
29
37
  Other package managers:
30
38
 
31
39
  ```sh
@@ -74,6 +82,9 @@ import { loadEnv } from "./env.mjs";
74
82
  export const env = loadEnv(process.env);
75
83
  ```
76
84
 
85
+ Celery validates the env object you pass. Load `.env` with your platform,
86
+ shell, or `dotenv` before calling `loadEnv`.
87
+
77
88
  That is the main path: write `env.schema.mjs`, generate `src/env.mjs`, and use
78
89
  the typed result everywhere else.
79
90
 
@@ -91,6 +102,15 @@ If you already use Zod for forms, API payloads, or general data validation,
91
102
  keep using it there. Celery is for the narrower `process.env` problem where
92
103
  defaults, examples, generated files, and startup cost matter.
93
104
 
105
+ ## Quick Comparison
106
+
107
+ | Choose | When |
108
+ | --- | --- |
109
+ | Celery generated mode | You want a committed standalone validator, generated TypeScript declarations, generated `.env.example`, no production dependency, or lower cold-start cost. |
110
+ | Celery runtime mode | You like Celery's schema API but cannot add generation yet. |
111
+ | Zod | You need general object, form, API, or nested data validation. |
112
+ | Envalid / Envsafe / env-var | You want mature runtime-only env validation with no generated files. |
113
+
94
114
  ## Why Use It
95
115
 
96
116
  - **Generated validator**: no schema walk during app startup.
@@ -118,20 +138,37 @@ export const env = parseEnv(schema, process.env);
118
138
 
119
139
  ## Benchmarks
120
140
 
121
- Current report: Node v26.3.0, macOS arm64, Apple M3.
122
-
123
- | Metric | Result |
124
- | --- | ---: |
125
- | Valid real-schema geometric mean | 1.50x over best external competitor |
126
- | Real `process.env` geometric mean | 1.14x over best external competitor |
127
- | Invalid real-schema geometric mean | 1.32x over best external competitor |
128
- | Cold first validation | 2.42x faster than best external competitor |
129
- | Generated validator size | 526 gzip bytes |
130
- | Smallest external bundle gap | 2.15x smaller |
131
-
132
- Competitors measured include Zod, Valibot, Envalid, Envsafe, env-var, T3 Env
133
- Core, Valienv, env-schema, env-type-validator, safe-env-vars, and Convict where
134
- the benchmark applies. The claim is specific to this env-validation corpus.
141
+ Snapshot: Node v26.3.0, macOS arm64, Apple M3, generated on 2026-06-24. Higher
142
+ ops/sec is better; lower milliseconds and bytes are better. Generated-mode
143
+ numbers exclude compile/generation cost.
144
+
145
+ | Tool / mode | Real schemas ops/sec | Real `process.env` ops/sec | Invalid ops/sec | Cold first validation | Gzip snapshot |
146
+ | --- | ---: | ---: | ---: | ---: | ---: |
147
+ | Celery generated | 1,411,473 | 228,570 | 112,361 | 1.849 ms | 526 B |
148
+ | Celery runtime | 776,241 | 185,124 | 96,846 | 2.598 ms | 2,779 B |
149
+ | Zod | 516,820 | 141,126 | 39,760 | 33.999 ms | 20,894 B |
150
+ | Valibot | 454,925 | 109,584 | 84,841 | 6.925 ms | 2,055 B |
151
+ | Envalid | 125,202 | 85,436 | 16,731 | 9.598 ms | 7,318 B |
152
+ | Envsafe | 940,564 | 184,818 | 19,359 | 5.694 ms | 3,292 B |
153
+ | env-var | 51,287 | 44,727 | 31,922 | 7.679 ms | 2,969 B |
154
+ | T3 Env Core | 316,627 | 168,740 | 10,264 | 32.366 ms | 19,531 B |
155
+
156
+ The benchmark corpus includes realistic API, web, worker, list-heavy, and
157
+ JSON-heavy env schemas. Results are workload-specific; real `process.env` access
158
+ narrows some gaps compared with frozen plain env objects.
159
+
160
+ Current npm package metadata:
161
+
162
+ | Package | Version checked | Runtime deps | Unpacked npm size | Files |
163
+ | --- | ---: | ---: | ---: | ---: |
164
+ | `celery-env` | 0.1.2 | 0 | 95.7 kB | 20 |
165
+ | `zod` | 4.4.3 | 0 | 4.56 MB | 718 |
166
+ | `valibot` | 1.4.1 | 0 | 1.84 MB | 9 |
167
+ | `envalid` | 8.2.0 | 1 | 88.8 kB | 39 |
168
+ | `envsafe` | 2.0.3 | 0 | 91.4 kB | 27 |
169
+ | `env-var` | 7.5.0 | 0 | 42.9 kB | 30 |
170
+
171
+ Metadata checked with `npm view` on 2026-06-25.
135
172
 
136
173
  ## Documentation
137
174
 
@@ -6,16 +6,34 @@ not the local benchmark lab.
6
6
 
7
7
  ## Current Headline
8
8
 
9
- Current report: Node v26.3.0, macOS arm64, Apple M3.
9
+ Snapshot: Node v26.3.0, V8 14.6.202.34-node.20, macOS arm64, Apple M3,
10
+ generated on 2026-06-24. Higher ops/sec is better; lower milliseconds and bytes
11
+ are better. Generated-mode numbers exclude compile/generation cost.
10
12
 
11
- | Metric | Result |
12
- | --- | ---: |
13
- | Valid real-schema geometric mean | 1.50x over best external competitor |
14
- | Real `process.env` geometric mean | 1.14x over best external competitor |
15
- | Invalid real-schema geometric mean | 1.32x over best external competitor |
16
- | Cold first validation | 2.42x faster than best external competitor |
17
- | Generated validator size | 526 gzip bytes |
18
- | Smallest external bundle gap | 2.15x smaller |
13
+ | Tool / mode | Primary use | Generated no-dep validator | Real schemas ops/sec geom mean | Real `process.env` ops/sec geom mean | Invalid ops/sec geom mean | Cold first validation | Gzip snapshot |
14
+ | --- | --- | --- | ---: | ---: | ---: | ---: | ---: |
15
+ | Celery generated | Env config | Yes | 1,411,473 | 228,570 | 112,361 | 1.849 ms | 526 B |
16
+ | Celery runtime | Env config | No | 776,241 | 185,124 | 96,846 | 2.598 ms | 2,779 B |
17
+ | Zod | General validation | No | 516,820 | 141,126 | 39,760 | 33.999 ms | 20,894 B |
18
+ | Valibot | General validation | No | 454,925 | 109,584 | 84,841 | 6.925 ms | 2,055 B |
19
+ | Envalid | Env validation | No | 125,202 | 85,436 | 16,731 | 9.598 ms | 7,318 B |
20
+ | Envsafe | Env validation | No | 940,564 | 184,818 | 19,359 | 5.694 ms | 3,292 B |
21
+ | env-var | Env accessors | No | 51,287 | 44,727 | 31,922 | 7.679 ms | 2,969 B |
22
+ | T3 Env Core | Typed env schema | No | 316,627 | 168,740 | 10,264 | 32.366 ms | 19,531 B |
23
+
24
+ Summary against the best external per-case baseline:
25
+
26
+ | Metric | Celery generated | Best external per-case baseline | Result |
27
+ | --- | ---: | ---: | ---: |
28
+ | Valid real-schema geometric mean | 1,411,473 ops/sec | 940,564 ops/sec | 1.50x |
29
+ | Real `process.env` geometric mean | 228,570 ops/sec | 200,908 ops/sec | 1.14x |
30
+ | Invalid real-schema geometric mean | 112,361 ops/sec | 84,841 ops/sec | 1.32x |
31
+ | Cold first validation | 1.849 ms | 4.466 ms | 2.42x faster |
32
+ | Shipped gzip snapshot | 526 B | 1,130 B | 2.15x smaller |
33
+
34
+ These are results from the env-validation corpus. They are useful for choosing
35
+ a tool, but they are not a guarantee for every schema, host runtime, or
36
+ deployment shape.
19
37
 
20
38
  ## What Is Measured
21
39
 
@@ -32,11 +50,59 @@ The benchmark corpus includes Zod, Valibot, Envalid, Envsafe, env-var, T3 Env
32
50
  Core, Valienv, env-schema, env-type-validator, safe-env-vars, and Convict where
33
51
  the benchmark applies.
34
52
 
53
+ Snapshot dependency versions:
54
+
55
+ | Package | Version |
56
+ | --- | ---: |
57
+ | `zod` | 4.4.3 |
58
+ | `valibot` | 1.4.1 |
59
+ | `envalid` | 8.2.0 |
60
+ | `envsafe` | 2.0.3 |
61
+ | `env-var` | 7.5.0 |
62
+ | `@t3-oss/env-core` | 0.13.11 |
63
+ | `valienv` | 1.1.0 |
64
+ | `env-schema` | 7.0.0 |
65
+ | `env-type-validator` | 1.0.1 |
66
+ | `safe-env-vars` | 1.0.8 |
67
+ | `convict` | 6.2.5 |
68
+
69
+ ## Package Metadata
70
+
71
+ Package footprint is separate from runtime speed. Current npm metadata checked
72
+ on 2026-06-25:
73
+
74
+ | Package | Version Checked | Runtime Deps | Unpacked npm Size | Files |
75
+ | --- | ---: | ---: | ---: | ---: |
76
+ | `celery-env` | 0.1.2 | 0 | 95.7 kB | 20 |
77
+ | `zod` | 4.4.3 | 0 | 4.56 MB | 718 |
78
+ | `valibot` | 1.4.1 | 0 | 1.84 MB | 9 |
79
+ | `envalid` | 8.2.0 | 1 | 88.8 kB | 39 |
80
+ | `envsafe` | 2.0.3 | 0 | 91.4 kB | 27 |
81
+ | `env-var` | 7.5.0 | 0 | 42.9 kB | 30 |
82
+
83
+ Celery's generated validator size can be much smaller than the package size
84
+ because generated mode ships only the emitted validator in production code.
85
+
86
+ ## Reproducibility
87
+
88
+ The published package intentionally does not ship the local benchmark lab or
89
+ competitor dependencies. Public docs keep the benchmark summary and claim rules
90
+ so the npm package stays small and dependency-free.
91
+
92
+ When refreshing claims, record:
93
+
94
+ - `celery-env` version or commit;
95
+ - Node version, operating system, CPU, and date;
96
+ - competitor package versions;
97
+ - whether the row uses frozen env objects or real `process.env`;
98
+ - whether errors are aggregate or fail-fast;
99
+ - generated validator gzip size separately from npm package size.
100
+
35
101
  ## Claim Rules
36
102
 
37
103
  Use precise benchmark claims:
38
104
 
39
- - Good: "1.50x over the best external competitor on the real-schema corpus."
105
+ - Good: "1.50x over the best external per-case baseline on the real-schema corpus."
40
106
  - Good: "526 gzip bytes for the measured small generated validator."
41
107
  - Avoid: "Celery is always 50x faster."
42
108
 
package/docs/CLI.md CHANGED
@@ -45,6 +45,7 @@ Use `generate` after editing `env.schema.mjs`.
45
45
  | `--fail-fast` | Throw on the first error instead of aggregating errors. |
46
46
  | `--force` | Overwrite existing generated files. |
47
47
  | `--optimize speed` | Emit larger speed-prioritized code for supported cases. |
48
+ | `--version`, `-v` | Print the installed CLI version. |
48
49
 
49
50
  ## Recommended App Command
50
51
 
@@ -53,7 +54,7 @@ Add a script:
53
54
  ```json
54
55
  {
55
56
  "scripts": {
56
- "env:generate": "celery-env generate --schema env.schema.mjs --out src/env.mjs --types src/env.d.ts --example .env.example --minify"
57
+ "env:generate": "celery-env generate --schema env.schema.mjs --out src/env.mjs --types src/env.d.ts --example .env.example --minify --force"
57
58
  }
58
59
  }
59
60
  ```
@@ -3,6 +3,16 @@
3
3
  Celery is not a replacement for general validation libraries. It is a focused
4
4
  tool for environment configuration.
5
5
 
6
+ ## Quick Decision Guide
7
+
8
+ | Choose | When |
9
+ | --- | --- |
10
+ | Celery generated mode | You want a committed standalone validator, generated TypeScript declarations, generated `.env.example`, no production dependency, or lower cold-start cost. |
11
+ | Celery runtime mode | You like Celery's schema API but cannot add generation yet. |
12
+ | Zod | You already need general object validation, nested data validation, or shared schemas beyond `process.env`. |
13
+ | Envalid / Envsafe / env-var | You want a mature runtime-only env validator with no generated files or build step. |
14
+ | Stay with your current tool | Env validation is not on the startup path and generated artifacts would add workflow friction. |
15
+
6
16
  ## Celery vs Zod
7
17
 
8
18
  Zod is excellent for general TypeScript validation: forms, API payloads,
@@ -38,15 +48,41 @@ Celery's main difference is generated mode:
38
48
  - TypeScript declarations can be generated from the same schema;
39
49
  - `.env.example` can be generated from schema metadata.
40
50
 
41
- ## Choosing A Mode
51
+ ## Generated vs Runtime Mode
52
+
53
+ | Question | Generated mode | Runtime mode |
54
+ | --- | --- | --- |
55
+ | Production dependency on `celery-env` | No | Yes |
56
+ | Build or generate step | Yes | No |
57
+ | Startup cost | Lowest | Schema parsed at runtime |
58
+ | Generated `.d.ts` | Yes | Use `InferEnv` |
59
+ | Generated `.env.example` | Yes | Only if you run the CLI |
60
+ | Best fit | Services, serverless, production apps | Scripts, prototypes, no-build projects |
61
+
62
+ ## Choose Another Tool When
42
63
 
43
- | Use Case | Recommended Mode |
64
+ | Need | Better Fit |
44
65
  | --- | --- |
45
- | Production app or service | Generated |
46
- | Serverless or cold-start-sensitive app | Generated |
47
- | CLI script or small internal tool | Runtime |
48
- | Prototype before deciding schema shape | Runtime |
49
- | App with no build/generate step allowed | Runtime |
66
+ | General object, form, API, or nested JSON validation | Zod |
67
+ | Runtime-only env validation with no generated artifacts | Envalid / Envsafe / env-var |
68
+ | Built-in `.env` file loading | dotenv plus a validator |
69
+ | Maximum ecosystem maturity | Zod or established env validators |
70
+ | No schema execution during build or generation | A static config format or runtime-only validator |
71
+
72
+ ## Package Footprint
73
+
74
+ This table is npm package metadata, not benchmark speed:
75
+
76
+ | Package | Version Checked | Runtime Deps | Unpacked npm Size | Files |
77
+ | --- | ---: | ---: | ---: | ---: |
78
+ | `celery-env` | 0.1.2 | 0 | 95.7 kB | 20 |
79
+ | `zod` | 4.4.3 | 0 | 4.56 MB | 718 |
80
+ | `valibot` | 1.4.1 | 0 | 1.84 MB | 9 |
81
+ | `envalid` | 8.2.0 | 1 | 88.8 kB | 39 |
82
+ | `envsafe` | 2.0.3 | 0 | 91.4 kB | 27 |
83
+ | `env-var` | 7.5.0 | 0 | 42.9 kB | 30 |
84
+
85
+ Checked with `npm view` on 2026-06-25.
50
86
 
51
87
  ## What Celery Does Not Do
52
88
 
@@ -1,7 +1,6 @@
1
1
  # Getting Started
2
2
 
3
- This guide assumes you have a Node project and want one reliable place to define
4
- environment variables.
3
+ This guide shows the generated-validator path for a Node project.
5
4
 
6
5
  ## 1. Install
7
6
 
@@ -15,12 +14,12 @@ yarn add -D celery-env
15
14
  bun add -d celery-env
16
15
  ```
17
16
 
18
- Use a dev dependency when you generate validators. The generated output has no
19
- runtime dependency on `celery-env`.
17
+ Generated validators do not need `celery-env` at runtime, so most apps install
18
+ it as a dev dependency.
20
19
 
21
20
  ## 2. Create A Schema
22
21
 
23
- Create `env.schema.mjs` in your project root:
22
+ Create `env.schema.mjs`:
24
23
 
25
24
  ```js
26
25
  import { bool, defineEnv, int, oneOf, str, url } from "celery-env";
@@ -63,7 +62,7 @@ npx celery-env generate \
63
62
  --minify
64
63
  ```
65
64
 
66
- This creates:
65
+ The command writes:
67
66
 
68
67
  | File | Purpose |
69
68
  | --- | --- |
@@ -71,12 +70,12 @@ This creates:
71
70
  | `src/env.d.ts` | Types for editors and TypeScript. |
72
71
  | `.env.example` | Documented env template. |
73
72
 
74
- Add the command to `package.json` so the workflow is repeatable:
73
+ Add a script so generation is repeatable:
75
74
 
76
75
  ```json
77
76
  {
78
77
  "scripts": {
79
- "env:generate": "celery-env generate --schema env.schema.mjs --out src/env.mjs --types src/env.d.ts --example .env.example --minify"
78
+ "env:generate": "celery-env generate --schema env.schema.mjs --out src/env.mjs --types src/env.d.ts --example .env.example --minify --force"
80
79
  }
81
80
  }
82
81
  ```
@@ -89,12 +88,14 @@ import { loadEnv } from "./env.mjs";
89
88
  export const env = loadEnv(process.env);
90
89
  ```
91
90
 
91
+ Celery validates the env object you pass. Load `.env` with your platform,
92
+ shell, or `dotenv` before calling `loadEnv`.
93
+
92
94
  Use `env` everywhere else instead of reading `process.env` directly.
93
95
 
94
- ## 5. Use A Nested App Config
96
+ ## 5. Optional: Shape App Config
95
97
 
96
- If your app already expects a nested config object, keep the generated env flat
97
- and adapt it in one small file:
98
+ Keep the generated env flat. If your app wants nested config, adapt it once:
98
99
 
99
100
  ```js
100
101
  import { loadEnv } from "./env.mjs";
@@ -126,6 +127,21 @@ For most apps, commit all of these:
126
127
  Committing generated files keeps deployments simple because production does not
127
128
  need to run the generator.
128
129
 
130
+ ## Keep Generated Files In Sync
131
+
132
+ Regenerate after changing `env.schema.mjs`:
133
+
134
+ ```sh
135
+ npm run env:generate
136
+ ```
137
+
138
+ In CI, verify generated files are current:
139
+
140
+ ```sh
141
+ npm run env:generate
142
+ git diff --exit-code src/env.mjs src/env.d.ts .env.example
143
+ ```
144
+
129
145
  ## Next Steps
130
146
 
131
147
  - Read [Schema API](SCHEMA.md) when adding validators.
package/docs/README.md CHANGED
@@ -9,7 +9,7 @@ Start here if you are new to Celery or new to typed env validation.
9
9
  3. [CLI](CLI.md): `init`, `generate`, and generation flags.
10
10
  4. [TypeScript](TYPESCRIPT.md): generated types and `InferEnv`.
11
11
  5. [Runtime Mode](RUNTIME.md): use Celery without generation.
12
- 6. [Comparison](COMPARISON.md): when to use Celery instead of general validators.
12
+ 6. [Comparison](COMPARISON.md): when to choose Celery, Zod, Envalid, Envsafe, or runtime-only env validators.
13
13
  7. [Troubleshooting](TROUBLESHOOTING.md): common setup and generation issues.
14
14
  8. [Migration Guide](MIGRATION.md): add Celery to an existing project.
15
15
  9. [Benchmarks](BENCHMARKS.md): what is measured and what claims are safe.
package/docs/SCHEMA.md CHANGED
@@ -41,8 +41,8 @@ options describe defaults, examples, and environment-specific behavior.
41
41
  | `example` | Example value used in generated `.env.example`. |
42
42
  | `docs` | Longer documentation text for generated metadata. |
43
43
 
44
- `testDefault` wins over `devDefault`, and `default` applies in every
45
- environment.
44
+ `testDefault` wins over `devDefault`, and both env-specific defaults win over
45
+ `default`.
46
46
 
47
47
  ## Missing Values
48
48
 
@@ -70,8 +70,9 @@ int({ min: 1, max: 65535 })
70
70
  num({ min: 0, max: 1 })
71
71
  ```
72
72
 
73
- By default, numeric parsing follows JavaScript `Number()`. Use `strict: true`
74
- to reject values such as hex and exponent notation:
73
+ By default, numeric parsing follows JavaScript `Number()`. For ports, limits,
74
+ rates, and other config values, prefer `strict: true` to reject values such as
75
+ hex and exponent notation:
75
76
 
76
77
  ```js
77
78
  int({ strict: true })
@@ -1,7 +1,29 @@
1
1
  <svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
2
2
  <title id="title">celery-env mark</title>
3
- <desc id="desc">A compact green celery-env leaf mark.</desc>
4
- <rect width="128" height="128" rx="28" fill="#0F766E"/>
5
- <path d="M71 19c-7 9-11 18-12 27 12-3 23-11 34-24 3 21-9 37-31 45 12 6 24 7 38 3-13 18-30 25-50 22-8-1-15-5-20-10 8-2 15-5 21-10-13-3-22-12-26-26 13 7 25 10 36 7-2-10 1-21 10-34Z" fill="#D9F99D"/>
6
- <path d="M38 103c16-2 32-2 48 0" stroke="#ECFDF5" stroke-width="8" stroke-linecap="round"/>
3
+ <desc id="desc">A dark green terminal-inspired C mark with celery leaves.</desc>
4
+ <defs>
5
+ <linearGradient id="bg" x1="18" y1="10" x2="110" y2="118" gradientUnits="userSpaceOnUse">
6
+ <stop stop-color="#064E3B"/>
7
+ <stop offset="1" stop-color="#022C22"/>
8
+ </linearGradient>
9
+ <linearGradient id="leaf" x1="38" y1="18" x2="94" y2="108" gradientUnits="userSpaceOnUse">
10
+ <stop stop-color="#ECFCCB"/>
11
+ <stop offset="0.48" stop-color="#86EFAC"/>
12
+ <stop offset="1" stop-color="#22C55E"/>
13
+ </linearGradient>
14
+ <filter id="shadow" x="13" y="16" width="102" height="99" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
15
+ <feDropShadow dx="0" dy="8" stdDeviation="7" flood-color="#011B16" flood-opacity="0.35"/>
16
+ </filter>
17
+ </defs>
18
+ <rect width="128" height="128" rx="30" fill="url(#bg)"/>
19
+ <path d="M104 78c0 18-13 29-36 29H46c-14 0-24-10-24-24V54c0-23 15-37 40-37h42v61Z" fill="#0F766E" opacity="0.42"/>
20
+ <g filter="url(#shadow)">
21
+ <path d="M87 92c-7 7-16 11-27 11-23 0-39-16-39-39 0-24 17-40 41-40 11 0 20 4 27 11" stroke="url(#leaf)" stroke-width="15" stroke-linecap="round"/>
22
+ <path d="M69 24c4-10 12-15 24-15-1 12-8 20-21 24" fill="#BBF7D0"/>
23
+ <path d="M57 25c-2-10-8-17-19-20-2 12 3 21 16 28" fill="#4ADE80"/>
24
+ <path d="M66 30c2-11 9-20 21-25 1 14-5 24-19 32" fill="#86EFAC"/>
25
+ </g>
26
+ <path d="M48 57l13 9-13 9" stroke="#ECFDF5" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
27
+ <path d="M70 77h24" stroke="#D9F99D" stroke-width="7" stroke-linecap="round"/>
28
+ <path d="M38 33h50" stroke="#ECFDF5" stroke-opacity="0.18" stroke-width="4" stroke-linecap="round"/>
7
29
  </svg>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "celery-env",
3
- "version": "0.1.0",
4
- "description": "Zero-dependency environment validation with generated standalone validators.",
3
+ "version": "0.1.2",
4
+ "description": "Type-safe process.env validation that generates zero-dependency standalone validators.",
5
5
  "type": "module",
6
6
  "types": "./src/index.d.ts",
7
7
  "sideEffects": false,
@@ -55,20 +55,31 @@
55
55
  "size": "node scripts/size.mjs",
56
56
  "security:scan": "node scripts/security-scan.mjs && npm run prepublishOnly",
57
57
  "validate:publish": "node scripts/validate-publish.mjs",
58
- "prepublishOnly": "npm test && npm run size && npm run validate:publish",
58
+ "smoke:pack": "node scripts/smoke-pack.mjs",
59
+ "prepublishOnly": "npm test && npm run size && npm run validate:publish && npm run smoke:pack",
59
60
  "demo:generate": "node src/cli.js --schema examples/env.schema.mjs --out .tmp/generated/env.mjs --types .tmp/generated/env.d.ts"
60
61
  },
61
62
  "keywords": [
62
63
  "env",
63
- "validation",
64
+ "env-var",
65
+ "env-vars",
64
66
  "environment",
67
+ "environment-variables",
68
+ "process-env",
69
+ "env-validation",
70
+ "env-validator",
71
+ "validation",
65
72
  "config",
73
+ "configuration",
74
+ "config-validation",
66
75
  "schema",
76
+ "type-safe",
67
77
  "typescript",
68
- "serverless",
69
- "zod",
78
+ "typed-env",
79
+ "node",
70
80
  "dotenv",
71
- "cli"
81
+ "cli",
82
+ "serverless"
72
83
  ],
73
84
  "license": "MIT",
74
85
  "engines": {
package/src/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { constants } from "node:fs";
3
- import { mkdir, open, writeFile } from "node:fs/promises";
3
+ import { mkdir, open, readFile, writeFile } from "node:fs/promises";
4
4
  import { dirname, resolve } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
6
 
@@ -9,6 +9,10 @@ const NOFOLLOW = constants.O_NOFOLLOW || 0;
9
9
  const args = parseArgs(process.argv.slice(2));
10
10
 
11
11
  if (args.help) usage(0);
12
+ if (args.version) {
13
+ console.log(await packageVersion());
14
+ process.exit(0);
15
+ }
12
16
 
13
17
  if (args.command === "init") {
14
18
  await init(args);
@@ -61,6 +65,7 @@ function parseArgs(argv) {
61
65
  for (let i = 0; i < argv.length; i += 1) {
62
66
  const arg = argv[i];
63
67
  if (arg === "--help" || arg === "-h") out.help = true;
68
+ else if (arg === "--version" || arg === "-v") out.version = true;
64
69
  else if (arg === "--schema") out.schema = argv[++i];
65
70
  else if (arg === "--target") out.target = argv[++i];
66
71
  else if (arg === "--out") out.out = argv[++i];
@@ -124,10 +129,16 @@ export default defineEnv({
124
129
  throw new Error(`Unknown init target: ${target}`);
125
130
  }
126
131
 
132
+ async function packageVersion() {
133
+ const pkg = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
134
+ return pkg.version;
135
+ }
136
+
127
137
  function usage(code) {
128
138
  console.log(`Usage:
129
139
  celery-env --schema env.schema.mjs --out src/env.mjs [--types src/env.d.ts]
130
140
  celery-env generate --schema env.schema.mjs --out src/env.mjs [--types src/env.d.ts] [--example .env.example] [--force] [--optimize speed]
131
- celery-env init --target node|next|vite --schema env.schema.mjs`);
141
+ celery-env init --target node|next|vite --schema env.schema.mjs
142
+ celery-env --version`);
132
143
  process.exit(code);
133
144
  }
package/src/compiler.js CHANGED
@@ -20,6 +20,7 @@ export function generateValidator(schema, options = {}) {
20
20
  if (rule.t === LIST && needsListTempVar(rule)) needsListTemp = true;
21
21
  body.push(...emitRule(key, rule, `_${i}`, ctx));
22
22
  }
23
+ if (!ctx.f) envError(ctx);
23
24
  const lines = ctx.g;
24
25
  lines.push(`export function ${fn}(${param}) {`);
25
26
  if (!ctx.f) lines.push(" let r;");
@@ -28,7 +29,7 @@ export function generateValidator(schema, options = {}) {
28
29
  if (needsListTemp) lines.push(" let x;");
29
30
  lines.push(...body);
30
31
 
31
- if (!ctx.f) lines.push(" if (r) throw Error(\"Invalid environment:\\n- \" + r.join(\"\\n- \"));");
32
+ if (!ctx.f) lines.push(" if (r) R(r);");
32
33
  lines.push(
33
34
  ` return ${returnObject(entries)};`,
34
35
  "}",
@@ -48,13 +49,14 @@ function generateObjectValidator(entries, fn, param, ctx, options) {
48
49
  if (rule.t === LIST && needsListTempVar(rule)) needsListTemp = true;
49
50
  body.push(...emitRule(key, rule, `o${prop(key)}`, ctx));
50
51
  }
52
+ if (!ctx.f) envError(ctx);
51
53
  const lines = ctx.g;
52
54
  lines.push(`export function ${fn}(${param}) {`);
53
55
  if (!ctx.f) lines.push(" let r;");
54
56
  lines.push(" let v;", " const o = {};");
55
57
  if (needsListTemp) lines.push(" let x;");
56
58
  lines.push(...body);
57
- if (!ctx.f) lines.push(" if (r) throw Error(\"Invalid environment:\\n- \" + r.join(\"\\n- \"));");
59
+ if (!ctx.f) lines.push(" if (r) R(r);");
58
60
  lines.push(" return o;", "}", `export default ${fn};`, "");
59
61
  if (options.minify) return lines.map((line) => line.trim()).join("");
60
62
  return lines.join("\n");
@@ -66,6 +68,7 @@ function generateSplitValidator(entries, fn, param, ctx, options) {
66
68
  for (let start = 0; start < entries.length; start += 32) {
67
69
  lines.push(...emitSplitChunk(entries, start, Math.min(start + 32, entries.length), n++, ctx));
68
70
  }
71
+ if (!ctx.f) envError(ctx);
69
72
 
70
73
  lines.push(`export function ${fn}(${param}) {`);
71
74
  if (!ctx.f) lines.push(" let r;");
@@ -73,7 +76,7 @@ function generateSplitValidator(entries, fn, param, ctx, options) {
73
76
  for (let i = 0; i < n; i++) {
74
77
  lines.push(ctx.f ? ` _c${i}(env, a);` : ` r = _c${i}(env, a, r);`);
75
78
  }
76
- if (!ctx.f) lines.push(" if (r) throw Error(\"Invalid environment:\\n- \" + r.join(\"\\n- \"));");
79
+ if (!ctx.f) lines.push(" if (r) R(r);");
77
80
  lines.push(
78
81
  ` return ${returnObject(entries, "a")};`,
79
82
  "}",
@@ -221,6 +224,8 @@ function assertSchema(schema) {
221
224
  const entries = Object.entries(schema);
222
225
  for (const [key, rule] of entries) {
223
226
  if (!(rule && typeof rule === "object" && H(rule, "t") && typeof rule.t === "number")) throw new TypeError(`${key}: invalid celery-env spec`);
227
+ assertRuleOptions(key, rule);
228
+ if (rule.t === LIST && !(rule.item && typeof rule.item === "object" && H(rule.item, "t") && typeof rule.item.t === "number")) throw new TypeError(`${key}: list item is not a celery-env spec`);
224
229
  if (rule.t === LIST && rule.item.t === LIST) throw new TypeError(`${key}: nested list generation is not supported`);
225
230
  if (rule.requiredWhen != null && typeof rule.requiredWhen !== "function") throw new TypeError(`${key}: requiredWhen must be a function`);
226
231
  for (const field of ["default", "devDefault", "testDefault"]) {
@@ -232,6 +237,28 @@ function assertSchema(schema) {
232
237
  return entries;
233
238
  }
234
239
 
240
+ function assertRuleOptions(key, rule) {
241
+ for (const field of ["min", "max"]) {
242
+ if (H(rule, field) && !Number.isFinite(rule[field])) throw new TypeError(`${key}: ${field} must be a finite number`);
243
+ }
244
+ if (H(rule, "strict") && typeof rule.strict !== "boolean") throw new TypeError(`${key}: strict must be a boolean`);
245
+ if (H(rule, "optional") && typeof rule.optional !== "boolean") throw new TypeError(`${key}: optional must be a boolean`);
246
+ if (H(rule, "startsWith") && typeof rule.startsWith !== "string") throw new TypeError(`${key}: startsWith must be a string`);
247
+ if (H(rule, "includes") && typeof rule.includes !== "string") throw new TypeError(`${key}: includes must be a string`);
248
+ if (H(rule, "protocols") && (!Array.isArray(rule.protocols) || rule.protocols.some(p => typeof p !== "string" || p === "" || p.includes(":")))) {
249
+ throw new TypeError(`${key}: protocols must be protocol names without ":"`);
250
+ }
251
+ if (rule.t === URL_T && H(rule, "protocols") && (!Array.isArray(rule.ps) || rule.ps.length !== rule.protocols.length || rule.ps.some((p, i) => p !== `${rule.protocols[i]}:`))) {
252
+ throw new TypeError(`${key}: malformed URL protocol spec`);
253
+ }
254
+ if (H(rule, "separator") && typeof rule.separator !== "string") throw new TypeError(`${key}: separator must be a string`);
255
+ if (H(rule, "trim") && typeof rule.trim !== "boolean") throw new TypeError(`${key}: trim must be a boolean`);
256
+ if (rule.t === ENUM && (!Array.isArray(rule.values) || !rule.values.length || rule.values.some(v => typeof v !== "string" && typeof v !== "number" && typeof v !== "boolean" || typeof v === "number" && !Number.isFinite(v)))) {
257
+ throw new TypeError(`${key}: oneOf values must be strings, finite numbers, or booleans`);
258
+ }
259
+ if (rule.t === LIST && rule.item && typeof rule.item === "object") assertRuleOptions(`${key} item`, rule.item);
260
+ }
261
+
235
262
  function validDefault(rule, value) {
236
263
  if (value === undefined) return rule.optional === true;
237
264
  switch (rule.t) {
@@ -299,7 +326,7 @@ function emitterContext(options) {
299
326
  if (optimize && optimize !== "default" && optimize !== "speed") throw new TypeError(`Unknown optimize mode: ${optimize}`);
300
327
  const t = options.splitLarge === false ? 1/0 : options.splitLargeThreshold ?? 512;
301
328
  if (!Number.isInteger(t) && t !== 1/0) throw new TypeError("splitLargeThreshold must be an integer");
302
- return { e: 0, f: options.failFast === true, g: [], h: 0, j: 0, s: optimize > "default", t };
329
+ return { e: 0, f: options.failFast === true, g: [], h: 0, j: 0, r: 0, s: optimize > "default", t };
303
330
  }
304
331
 
305
332
  function functionName(options) {
@@ -352,6 +379,10 @@ function envFacade(ctx) {
352
379
  return "E(env)";
353
380
  }
354
381
 
382
+ function envError(ctx) {
383
+ if(!ctx.r++)ctx.g.push(`function R(r){const e=Error("Invalid environment:\\n- "+r.join("\\n- "));e.name="EnvError";e.errors=r;throw e}`);
384
+ }
385
+
355
386
  function simpleMissing(rule) {
356
387
  return !hasDefault(rule) && !rule.requiredWhen;
357
388
  }
@@ -369,12 +400,12 @@ function emitMissing(key, rule, pad, target, out, ctx) {
369
400
  if (H(rule, "default")) {
370
401
  out.push(`${pad}${used ? "else " : ""}${target} = ${literal(rule.default)};`);
371
402
  } else if (rule.optional && rule.requiredWhen) {
372
- out.push(`${pad}${used ? "else " : ""}if (${requiredWhenExpr(rule)}(${envFacade(ctx)}) === true) ${err(`${key} is required`, ctx)};`);
403
+ out.push(`${pad}${used ? "else " : ""}if (${requiredWhenExpr(rule)}(${envFacade(ctx)}) === true) ${errMsg(key, " is required", ctx)};`);
373
404
  if (target > "b") out.push(`${pad}else ${target} = undefined;`);
374
405
  } else if (rule.optional) {
375
406
  out.push(`${pad}${used ? "else " : ""}${target} = undefined;`);
376
407
  } else {
377
- out.push(`${pad}${used ? "else " : ""}${err(`${key} is required`, ctx)};`);
408
+ out.push(`${pad}${used ? "else " : ""}${errMsg(key, " is required", ctx)};`);
378
409
  }
379
410
  }
380
411
 
@@ -403,16 +434,16 @@ function emitPresent(key, rule, pad, target, ctx, value = "v") {
403
434
 
404
435
  function emitJson(key, pad, target, value, ctx) {
405
436
  if(!ctx.j++)ctx.g.push(`function J(v){return v[0]=="{"&&v[v.length-1]!="}"||v[0]=="["&&v[v.length-1]!="]"}`);
406
- const er=err(`${key} must be valid JSON`,ctx);
437
+ const er=errMsg(key, " must be valid JSON",ctx);
407
438
  return [`${pad}try { if (J(${value})) throw 0; ${target} = JSON.parse(${value}); } catch { ${er}; }`];
408
439
  }
409
440
 
410
441
  function emitString(key, rule, pad, target, value, ctx) {
411
442
  const out = [];
412
- if (rule.min != null && !skipMin(rule)) out.push(`${pad}if (${value}.length < ${num(rule.min)}) ${err(`${key} must have length >= ${rule.min}`, ctx)};`);
413
- if (rule.max != null) out.push(`${out.length ? pad + "else " : pad}if (${value}.length > ${num(rule.max)}) ${err(`${key} must have length <= ${rule.max}`, ctx)};`);
414
- if (rule.startsWith != null) out.push(`${out.length ? pad + "else " : pad}if (!${value}.startsWith(${literal(rule.startsWith)})) ${err(`${key} must start with ${rule.startsWith}`, ctx)};`);
415
- if (rule.includes != null) out.push(`${out.length ? pad + "else " : pad}if (!${value}.includes(${literal(rule.includes)})) ${err(`${key} must include ${rule.includes}`, ctx)};`);
443
+ if (rule.min != null && !skipMin(rule)) out.push(`${pad}if (${value}.length < ${num(rule.min)}) ${errMsg(key, ` must have length >= ${rule.min}`, ctx)};`);
444
+ if (rule.max != null) out.push(`${out.length ? pad + "else " : pad}if (${value}.length > ${num(rule.max)}) ${errMsg(key, ` must have length <= ${rule.max}`, ctx)};`);
445
+ if (rule.startsWith != null) out.push(`${out.length ? pad + "else " : pad}if (!${value}.startsWith(${literal(rule.startsWith)})) ${errMsg(key, ` must start with ${rule.startsWith}`, ctx)};`);
446
+ if (rule.includes != null) out.push(`${out.length ? pad + "else " : pad}if (!${value}.includes(${literal(rule.includes)})) ${errMsg(key, ` must include ${rule.includes}`, ctx)};`);
416
447
  out.push(`${out.length ? pad + "else " : pad}${target} = ${value};`);
417
448
  return out;
418
449
  }
@@ -427,14 +458,14 @@ function emitNumber(key, rule, pad, target, integer, value, ctx) {
427
458
  const out = [];
428
459
  if (rule.strict) {
429
460
  const re = integer ? "/^[+-]?\\d+$/" : "/^[+-]?(?:\\d+\\.?\\d*|\\.\\d+)$/";
430
- out.push(`${pad}if (!${re}.test(${value})) ${err(`${key} must be ${integer ? "a strict integer" : "a strict number"}`, ctx)};`);
461
+ out.push(`${pad}if (!${re}.test(${value})) ${errMsg(key, ` must be ${integer ? "a strict integer" : "a strict number"}`, ctx)};`);
431
462
  out.push(`${pad}else {`);
432
463
  pad += " ";
433
464
  }
434
465
  out.push(`${pad}${value} = +${value};`);
435
- out.push(`${pad}if (${integer ? int32Bounded(rule) ? `(${value} | 0) !== ${value}` : `!Number.isInteger(${value})` : `!isFinite(${value})`}) ${err(`${key} must be ${integer ? "an integer" : "a number"}`, ctx)};`);
436
- if (rule.min != null) out.push(`${pad}else if (${value} < ${num(rule.min)}) ${err(`${key} must be >= ${rule.min}`, ctx)};`);
437
- if (rule.max != null) out.push(`${pad}else if (${value} > ${num(rule.max)}) ${err(`${key} must be <= ${rule.max}`, ctx)};`);
466
+ out.push(`${pad}if (${integer ? int32Bounded(rule) ? `(${value} | 0) !== ${value}` : `!Number.isInteger(${value})` : `!isFinite(${value})`}) ${errMsg(key, ` must be ${integer ? "an integer" : "a number"}`, ctx)};`);
467
+ if (rule.min != null) out.push(`${pad}else if (${value} < ${num(rule.min)}) ${errMsg(key, ` must be >= ${rule.min}`, ctx)};`);
468
+ if (rule.max != null) out.push(`${pad}else if (${value} > ${num(rule.max)}) ${errMsg(key, ` must be <= ${rule.max}`, ctx)};`);
438
469
  out.push(`${pad}else ${target} = ${value};`);
439
470
  if (rule.strict) out.push(`${pad.slice(0, -2)}}`);
440
471
  return out;
@@ -454,13 +485,13 @@ function emitStrictNumScalar(key, rule, pad, target, value, ctx) {
454
485
  `${pad} else if (c < 48 || c > 57) break;`,
455
486
  `${pad} else d = 1;`,
456
487
  `${pad} }`,
457
- `${pad} if (!d || q !== ${value}.length) ${err(`${key} must be a strict number`, ctx)};`,
488
+ `${pad} if (!d || q !== ${value}.length) ${errMsg(key, " must be a strict number", ctx)};`,
458
489
  `${pad} else {`,
459
490
  `${pad} ${value} = +${value};`,
460
- `${pad} if (!isFinite(${value})) ${err(`${key} must be a number`, ctx)};`
491
+ `${pad} if (!isFinite(${value})) ${errMsg(key, " must be a number", ctx)};`
461
492
  ];
462
- if (rule.min != null) out.push(`${pad} else if (${value} < ${num(rule.min)}) ${err(`${key} must be >= ${rule.min}`, ctx)};`);
463
- if (rule.max != null) out.push(`${pad} else if (${value} > ${num(rule.max)}) ${err(`${key} must be <= ${rule.max}`, ctx)};`);
493
+ if (rule.min != null) out.push(`${pad} else if (${value} < ${num(rule.min)}) ${errMsg(key, ` must be >= ${rule.min}`, ctx)};`);
494
+ if (rule.max != null) out.push(`${pad} else if (${value} > ${num(rule.max)}) ${errMsg(key, ` must be <= ${rule.max}`, ctx)};`);
464
495
  out.push(
465
496
  `${pad} else ${target} = ${value};`,
466
497
  `${pad} }`,
@@ -477,7 +508,7 @@ function emitStrictIntScalar(key, rule, pad, target, value, ctx) {
477
508
  `${pad} let c = ${value}.charCodeAt(q);`,
478
509
  `${pad} let g = 1;`,
479
510
  `${pad} if (c === 43 || c === 45) { g = c === 45 ? -1 : 1; q++; }`,
480
- `${pad} if (q === z) ${err(`${key} must be a strict integer`, ctx)};`,
511
+ `${pad} if (q === z) ${errMsg(key, " must be a strict integer", ctx)};`,
481
512
  `${pad} else {`,
482
513
  `${pad} let n = 0;`,
483
514
  `${pad} for (; q < z; q++) {`,
@@ -485,13 +516,13 @@ function emitStrictIntScalar(key, rule, pad, target, value, ctx) {
485
516
  `${pad} if (c < 48 || c > 57) break;`,
486
517
  `${pad} n = n * 10 + c - 48;`,
487
518
  `${pad} }`,
488
- `${pad} if (q !== z) ${err(`${key} must be a strict integer`, ctx)};`,
519
+ `${pad} if (q !== z) ${errMsg(key, " must be a strict integer", ctx)};`,
489
520
  `${pad} else {`,
490
521
  `${pad} n *= g;`,
491
- `${pad} if ((n | 0) !== n) ${err(`${key} must be an integer`, ctx)};`
522
+ `${pad} if ((n | 0) !== n) ${errMsg(key, " must be an integer", ctx)};`
492
523
  ];
493
- if (rule.min != null) out.push(`${pad} else if (n < ${num(rule.min)}) ${err(`${key} must be >= ${rule.min}`, ctx)};`);
494
- if (rule.max != null) out.push(`${pad} else if (n > ${num(rule.max)}) ${err(`${key} must be <= ${rule.max}`, ctx)};`);
524
+ if (rule.min != null) out.push(`${pad} else if (n < ${num(rule.min)}) ${errMsg(key, ` must be >= ${rule.min}`, ctx)};`);
525
+ if (rule.max != null) out.push(`${pad} else if (n > ${num(rule.max)}) ${errMsg(key, ` must be <= ${rule.max}`, ctx)};`);
495
526
  out.push(
496
527
  `${pad} else ${target} = n;`,
497
528
  `${pad} }`,
@@ -506,14 +537,14 @@ function emitEnum(key, rule, pad, target, value, ctx) {
506
537
  const checks = rule.values.map(v=>`${value} === ${literal(v)}`).join(" || ");
507
538
  return [
508
539
  `${pad}if (${checks}) ${target} = ${value};`,
509
- `${pad}else ${err(`${key} must be one of ${rule.values.join(", ")}`, ctx)};`
540
+ `${pad}else ${errMsg(key, ` must be one of ${rule.values.join(", ")}`, ctx)};`
510
541
  ];
511
542
  }
512
543
  const out = [`${pad}switch (${value}) {`];
513
544
  for (const v of rule.values) {
514
545
  out.push(`${pad} case ${literal(String(v))}: ${target} = ${literal(v)}; break;`);
515
546
  }
516
- out.push(`${pad} default: ${err(`${key} must be one of ${rule.values.join(", ")}`, ctx)};`);
547
+ out.push(`${pad} default: ${errMsg(key, ` must be one of ${rule.values.join(", ")}`, ctx)};`);
517
548
  out.push(`${pad}}`);
518
549
  return out;
519
550
  }
@@ -521,9 +552,9 @@ function emitEnum(key, rule, pad, target, value, ctx) {
521
552
  function emitUrl(key, rule, pad, target, value, ctx) {
522
553
  if (rule.protocols) {
523
554
  const cases = rule.ps.map((protocol) => `case ${literal(protocol)}:`).join(" ");
524
- return [`${pad}try { switch (new URL(${value}).protocol) { ${cases} ${target} = ${value}; break; default: ${err(`${key} must use protocol ${rule.protocols.join(", ")}`, ctx)}; } } catch { ${err(`${key} must be a URL`, ctx)}; }`];
555
+ return [`${pad}try { switch (new URL(${value}).protocol) { ${cases} ${target} = ${value}; break; default: ${errMsg(key, ` must use protocol ${rule.protocols.join(", ")}`, ctx)}; } } catch { ${errMsg(key, " must be a URL", ctx)}; }`];
525
556
  }
526
- return [`${pad}try { new URL(${value}); ${target} = ${value}; } catch { ${err(`${key} must be a URL`, ctx)}; }`];
557
+ return [`${pad}try { new URL(${value}); ${target} = ${value}; } catch { ${errMsg(key, " must be a URL", ctx)}; }`];
527
558
  }
528
559
 
529
560
  function emitList(key, rule, pad, target, ctx) {
@@ -589,10 +620,11 @@ function emitSetList(key, rule, pad, target, ctx) {
589
620
  const n = `S${ctx.g.length}`;
590
621
  ctx.g.push(`const ${n}=new Set(${literal(item.values)});`);
591
622
  const out = segmentListHeader(rule, pad, ctx);
623
+ const keyAt = listKey(key);
592
624
  out.push(
593
- `${pad} if (a === z) ${err(`${key} item is required`, ctx)};`,
625
+ `${pad} if (a === z) ${errMsg(keyAt, " is required", ctx)};`,
594
626
  `${pad} else if (${n}.has(x = v.slice(a, z))) l[i] = x;`,
595
- `${pad} else ${err(`${key} item must be one of ${item.values.join(", ")}`, ctx)};`
627
+ `${pad} else ${errMsg(keyAt, ` must be one of ${item.values.join(", ")}`, ctx)};`
596
628
  );
597
629
  return finishSegmentList(out, pad, target, ctx);
598
630
  }
@@ -600,14 +632,15 @@ function emitSetList(key, rule, pad, target, ctx) {
600
632
  function emitFastStrictIntList(key, rule, pad, target, ctx) {
601
633
  const item = rule.item;
602
634
  const out = segmentListHeader(rule, pad, ctx);
635
+ const keyAt = listKey(key);
603
636
  out.push(
604
- `${pad} if (a === z) ${"default" in item || item.optional ? `l[i] = ${literal(item.default)}` : err(`${key} item must be a strict integer`, ctx)};`,
637
+ `${pad} if (a === z) ${"default" in item || item.optional ? `l[i] = ${literal(item.default)}` : errMsg(keyAt, " must be a strict integer", ctx)};`,
605
638
  `${pad} else {`,
606
639
  `${pad} let q = a;`,
607
640
  `${pad} let c = v.charCodeAt(q);`,
608
641
  `${pad} let g = 1;`,
609
642
  `${pad} if (c === 43 || c === 45) { g = c === 45 ? -1 : 1; q++; }`,
610
- `${pad} if (q === z) ${err(`${key} item must be a strict integer`, ctx)};`,
643
+ `${pad} if (q === z) ${errMsg(keyAt, " must be a strict integer", ctx)};`,
611
644
  `${pad} else {`,
612
645
  `${pad} let n = 0;`,
613
646
  `${pad} for (; q < z; q++) {`,
@@ -615,13 +648,13 @@ function emitFastStrictIntList(key, rule, pad, target, ctx) {
615
648
  `${pad} if (c < 48 || c > 57) break;`,
616
649
  `${pad} n = n * 10 + c - 48;`,
617
650
  `${pad} }`,
618
- `${pad} if (q !== z) ${err(`${key} item must be a strict integer`, ctx)};`,
651
+ `${pad} if (q !== z) ${errMsg(keyAt, " must be a strict integer", ctx)};`,
619
652
  `${pad} else {`,
620
653
  `${pad} n *= g;`,
621
- `${pad} if ((n | 0) !== n) ${err(`${key} item must be an integer`, ctx)};`
654
+ `${pad} if ((n | 0) !== n) ${errMsg(keyAt, " must be an integer", ctx)};`
622
655
  );
623
- if (item.min != null) out.push(`${pad} else if (n < ${num(item.min)}) ${err(`${key} item must be >= ${item.min}`, ctx)};`);
624
- if (item.max != null) out.push(`${pad} else if (n > ${num(item.max)}) ${err(`${key} item must be <= ${item.max}`, ctx)};`);
656
+ if (item.min != null) out.push(`${pad} else if (n < ${num(item.min)}) ${errMsg(keyAt, ` must be >= ${item.min}`, ctx)};`);
657
+ if (item.max != null) out.push(`${pad} else if (n > ${num(item.max)}) ${errMsg(keyAt, ` must be <= ${item.max}`, ctx)};`);
625
658
  out.push(
626
659
  `${pad} else l[i] = n;`,
627
660
  `${pad} }`,
@@ -637,26 +670,26 @@ function emitFastStrictIntList(key, rule, pad, target, ctx) {
637
670
  function emitSegmentStringList(key, rule, pad, target, ctx) {
638
671
  const item = rule.item;
639
672
  const out = segmentListHeader(rule, pad, ctx);
640
- const label = `${key} item`;
673
+ const keyAt = listKey(key);
641
674
  const len = "z - a";
642
675
  let checked = false;
643
676
  if (item.min != null && !skipMin(item)) {
644
- out.push(`${pad} if (${len} < ${num(item.min)}) ${err(`${label} must have length >= ${item.min}`, ctx)};`);
677
+ out.push(`${pad} if (${len} < ${num(item.min)}) ${errMsg(keyAt, ` must have length >= ${item.min}`, ctx)};`);
645
678
  checked = true;
646
679
  }
647
680
  if (item.max != null) {
648
- out.push(`${checked ? pad + " else " : pad + " "}if (${len} > ${num(item.max)}) ${err(`${label} must have length <= ${item.max}`, ctx)};`);
681
+ out.push(`${checked ? pad + " else " : pad + " "}if (${len} > ${num(item.max)}) ${errMsg(keyAt, ` must have length <= ${item.max}`, ctx)};`);
649
682
  checked = true;
650
683
  }
651
684
  if (item.startsWith != null) {
652
685
  const prefix = literal(item.startsWith);
653
- out.push(`${checked ? pad + " else " : pad + " "}if (${len} < ${item.startsWith.length} || !v.startsWith(${prefix}, a)) ${err(`${label} must start with ${item.startsWith}`, ctx)};`);
686
+ out.push(`${checked ? pad + " else " : pad + " "}if (${len} < ${item.startsWith.length} || !v.startsWith(${prefix}, a)) ${errMsg(keyAt, ` must start with ${item.startsWith}`, ctx)};`);
654
687
  checked = true;
655
688
  }
656
689
  if (item.includes != null) {
657
690
  const needle = literal(item.includes);
658
691
  const needleLen = item.includes.length;
659
- out.push(`${checked ? pad + " else " : pad + " "}if (((x = v.indexOf(${needle}, a)) < 0) || x + ${needleLen} > z) ${err(`${label} must include ${item.includes}`, ctx)};`);
692
+ out.push(`${checked ? pad + " else " : pad + " "}if (((x = v.indexOf(${needle}, a)) < 0) || x + ${needleLen} > z) ${errMsg(keyAt, ` must include ${item.includes}`, ctx)};`);
660
693
  checked = true;
661
694
  }
662
695
  out.push(`${checked ? pad + " else " : pad + " "}l[i] = v.slice(a, z);`);
@@ -707,7 +740,7 @@ function listAssign(pad, target) {
707
740
  }
708
741
 
709
742
  function emitListItem(key, rule, pad, ctx) {
710
- key = `${key} item`;
743
+ key = listKey(key);
711
744
  if (rule.optional || !simpleMissing(rule)) {
712
745
  const out = [`${pad}if (x === "") {`];
713
746
  emitMissing(key, rule, `${pad} `, "l[i]", out, ctx);
@@ -721,7 +754,7 @@ function emitBool(key, pad, target, value, ctx) {
721
754
  return [
722
755
  `${pad}if (${value}==="true"||${value}==="1"||${value}==="yes"||${value}==="on") ${target} = true;`,
723
756
  `${pad}else if (${value}==="false"||${value}==="0"||${value}==="no"||${value}==="off") ${target} = false;`,
724
- `${pad}else ${err(`${key} must be a boolean`, ctx)};`
757
+ `${pad}else ${errMsg(key, " must be a boolean", ctx)};`
725
758
  ];
726
759
  }
727
760
 
@@ -747,7 +780,20 @@ function innerTypeFor(rule) {
747
780
  }
748
781
 
749
782
  function err(message, ctx) {
750
- return ctx.f ? `throw Error(${literal(`Invalid environment:\n- ${message}`)})` : `(r ??= []).push(${literal(message)})`;
783
+ return errExpr(literal(message), ctx);
784
+ }
785
+
786
+ function errMsg(key, suffix, ctx) {
787
+ return typeof key === "string" ? err(`${key}${suffix}`, ctx) : errExpr(`${key.expr}+${literal(suffix)}`, ctx);
788
+ }
789
+
790
+ function errExpr(message, ctx) {
791
+ envError(ctx);
792
+ return ctx.f ? `R([${message}])` : `(r ??= []).push(${message})`;
793
+ }
794
+
795
+ function listKey(key) {
796
+ return { expr: `${literal(key)}+"["+i+"]"` };
751
797
  }
752
798
 
753
799
  function literal(value) {
package/src/index.d.ts CHANGED
@@ -7,7 +7,7 @@ export type Spec<T> = { readonly [specBrand]: T };
7
7
  export type InferSpec<T> = T extends Spec<infer V> ? V : never;
8
8
  export type InferEnv<T extends Record<string, Spec<unknown>>> = { readonly [K in keyof T]: InferSpec<T[K]> };
9
9
  export type EnvResult<T extends Record<string, Spec<unknown>>> = InferEnv<T>;
10
- type Output<T, O> = O extends { default: infer D } ? D : O extends { optional: true } ? T | undefined : T;
10
+ type Output<T, O> = O extends { default: unknown } | { devDefault: unknown } | { testDefault: unknown } ? T : O extends { optional: true } ? T | undefined : T;
11
11
  export function defineEnv<T extends Record<string, Spec<unknown>>>(schema: T): Readonly<T>;
12
12
  export function str<O extends StringOptions | undefined = undefined>(options?: O): Spec<Output<string, O>>;
13
13
  export function int<O extends NumberOptions | undefined = undefined>(options?: O): Spec<Output<number, O>>;
package/src/index.js CHANGED
@@ -26,23 +26,34 @@ export function defineEnv(schema) {
26
26
  }
27
27
 
28
28
  export function str(options) {
29
+ assertBaseOptions("str", options);
30
+ assertStringOptions("str", options);
29
31
  return spec(STR, options);
30
32
  }
31
33
 
32
34
  export function int(options) {
35
+ assertBaseOptions("int", options);
36
+ assertNumberOptions("int", options);
33
37
  return spec(INT, options);
34
38
  }
35
39
 
36
40
  export function num(options) {
41
+ assertBaseOptions("num", options);
42
+ assertNumberOptions("num", options);
37
43
  return spec(NUM, options);
38
44
  }
39
45
 
40
46
  export function bool(options) {
47
+ assertBaseOptions("bool", options);
41
48
  return spec(BOOL, options);
42
49
  }
43
50
 
44
51
  export function oneOf(values, options) {
45
52
  if (!Array.isArray(values) || !values.length) throw new TypeError("oneOf() expects a non-empty array");
53
+ if (values.some(v => typeof v !== "string" && typeof v !== "number" && typeof v !== "boolean" || typeof v === "number" && !Number.isFinite(v))) {
54
+ throw new TypeError("oneOf() values must be strings, finite numbers, or booleans");
55
+ }
56
+ assertBaseOptions("oneOf", options);
46
57
  const mixed = values.some(v=>typeof v!="string");
47
58
  const strings = mixed ? values.map(String) : values.slice();
48
59
  const rule = {...options,values,strings};
@@ -51,15 +62,24 @@ export function oneOf(values, options) {
51
62
  }
52
63
 
53
64
  export function url(options) {
65
+ assertBaseOptions("url", options);
66
+ assertStringOptions("url", options);
67
+ if (options && H(options, "protocols") && (!Array.isArray(options.protocols) || options.protocols.some(p => typeof p !== "string" || p === "" || p.includes(":")))) {
68
+ throw new TypeError("url() protocols must be protocol names without \":\"");
69
+ }
54
70
  return spec(URL_T, options && H(options, "protocols") ? {...options,ps:options.protocols.map(p=>p+":")} : options);
55
71
  }
56
72
 
57
73
  export function json(options) {
74
+ assertBaseOptions("json", options);
58
75
  return spec(JSON_T, options);
59
76
  }
60
77
 
61
78
  export function list(item, options) {
62
79
  if (!isCelerySpec(item)) throw new TypeError("list() expects a celery-env spec item");
80
+ assertBaseOptions("list", options);
81
+ if (options && H(options, "separator") && typeof options.separator !== "string") throw new TypeError("list() separator must be a string");
82
+ if (options && H(options, "trim") && typeof options.trim !== "boolean") throw new TypeError("list() trim must be a boolean");
63
83
  return spec(LIST, {separator:",",...options,item});
64
84
  }
65
85
 
@@ -92,6 +112,34 @@ function schemaEntries(schema) {
92
112
  return entries;
93
113
  }
94
114
 
115
+ function assertBaseOptions(name, options) {
116
+ if (options === undefined) return;
117
+ if (options === null || typeof options !== "object" || Array.isArray(options)) throw new TypeError(`${name}() options must be an object`);
118
+ if (H(options, "optional") && typeof options.optional !== "boolean") throw new TypeError(`${name}() optional must be a boolean`);
119
+ if (H(options, "requiredWhen") && options.requiredWhen != null && typeof options.requiredWhen !== "function") throw new TypeError(`${name}() requiredWhen must be a function`);
120
+ if (H(options, "desc") && typeof options.desc !== "string") throw new TypeError(`${name}() desc must be a string`);
121
+ if (H(options, "docs") && typeof options.docs !== "string") throw new TypeError(`${name}() docs must be a string`);
122
+ }
123
+
124
+ function assertStringOptions(name, options) {
125
+ if (!options) return;
126
+ assertLimit(name, options, "min");
127
+ assertLimit(name, options, "max");
128
+ if (H(options, "startsWith") && typeof options.startsWith !== "string") throw new TypeError(`${name}() startsWith must be a string`);
129
+ if (H(options, "includes") && typeof options.includes !== "string") throw new TypeError(`${name}() includes must be a string`);
130
+ }
131
+
132
+ function assertNumberOptions(name, options) {
133
+ if (!options) return;
134
+ assertLimit(name, options, "min");
135
+ assertLimit(name, options, "max");
136
+ if (H(options, "strict") && typeof options.strict !== "boolean") throw new TypeError(`${name}() strict must be a boolean`);
137
+ }
138
+
139
+ function assertLimit(name, options, key) {
140
+ if (H(options, key) && !Number.isFinite(options[key])) throw new TypeError(`${name}() ${key} must be a finite number`);
141
+ }
142
+
95
143
  function readValue(key, rule, value, e, i, env) {
96
144
  if (value == null || value === "") {
97
145
  if (H(rule, "testDefault") || H(rule, "devDefault")) {