Fast and tiny JavaScript/TypeScript cron parser with timezone support
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

throw on `parse` method errors too. fixed typecheck errors, updated benchmarks

+496 -221
+20 -19
README.md
··· 115 115 116 116 ### `parse(expression)` 117 117 118 - Parse a cron expression into its components. 118 + Parse a cron expression into its components. **Throws** if the expression is invalid. 119 119 120 120 ```typescript 121 121 parse("0 9 * * 1-5"); ··· 124 124 125 125 ### `describe(expression)` 126 126 127 - Get a human-readable description of a cron expression. 127 + Get a human-readable description of a cron expression. Returns `"Invalid cron expression"` if the expression is invalid. 128 128 129 129 ```typescript 130 130 describe("*/5 * * * *"); // "Every 5 minutes" 131 131 describe("0 9 * * 1-5"); // "At minute 0, between 9 AM and 5 PM, on weekdays" 132 132 describe("*/15 3,4 1-4 */3 6"); 133 133 // "Every 15 minutes, at 3 AM, 4 AM, on days 1-4 of the month or on Saturday, every 3 months" 134 + describe("invalid"); // "Invalid cron expression" 134 135 ``` 135 136 136 137 ## Cron Expression Format ··· 172 173 173 174 ## Bundle Size 174 175 175 - cron-fast is extremely lightweight and fully tree-shakeable. Here are the actual bundle sizes for different import scenarios (tested with v1.0.0): 176 + cron-fast is extremely lightweight and fully tree-shakeable. Here are the actual bundle sizes for different import scenarios (tested with v2.0.1): 176 177 177 178 | Import | Raw | Minified | Gzipped | 178 179 | ------------------------------------------------------ | -------- | -------- | ----------- | 179 - | `Full bundle (all exports) ` | 21.09 KB | 9.52 KB | **3.46 KB** | 180 - | `nextRun only ` | 12.32 KB | 5.41 KB | **2.05 KB** | 181 - | `previousRun only ` | 12.33 KB | 5.41 KB | **2.05 KB** | 182 - | `nextRuns only ` | 12.73 KB | 5.58 KB | **2.12 KB** | 183 - | `isValid only ` | 3.71 KB | 1.69 KB | **843 B** | 184 - | `parse only ` | 3.64 KB | 1.66 KB | **830 B** | 185 - | `describe only ` | 10.82 KB | 5.02 KB | **1.98 KB** | 186 - | `isMatch only ` | 5.68 KB | 2.62 KB | **1.20 KB** | 187 - | `Validation only (isValid + parse) ` | 3.72 KB | 1.69 KB | **844 B** | 188 - | `Scheduling only (nextRun + previousRun + nextRuns) ` | 13.17 KB | 5.78 KB | **2.14 KB** | 180 + | `Full bundle (all exports) ` | 21.86 KB | 10.12 KB | **3.61 KB** | 181 + | `nextRun only ` | 13.12 KB | 6.03 KB | **2.21 KB** | 182 + | `previousRun only ` | 13.13 KB | 6.03 KB | **2.22 KB** | 183 + | `nextRuns only ` | 13.50 KB | 6.18 KB | **2.28 KB** | 184 + | `isValid only ` | 4.44 KB | 2.23 KB | **980 B** | 185 + | `parse only ` | 4.33 KB | 2.19 KB | **956 B** | 186 + | `describe only ` | 11.55 KB | 5.58 KB | **2.11 KB** | 187 + | `isMatch only ` | 6.35 KB | 3.15 KB | **1.33 KB** | 188 + | `Validation only (isValid + parse) ` | 4.45 KB | 2.24 KB | **981 B** | 189 + | `Scheduling only (nextRun + previousRun + nextRuns) ` | 13.91 KB | 6.36 KB | **2.30 KB** | 189 190 190 191 Import only what you need: 191 192 ··· 272 273 273 274 ## Tips & Gotchas 274 275 275 - - **Invalid input throws**: `nextRun`, `previousRun`, `nextRuns`, and `isMatch` throw an error for invalid cron expressions or invalid timezones. Use `isValid()` to pre-validate user input, or wrap calls in try/catch. 276 + - **Invalid input throws**: Most functions (`nextRun`, `previousRun`, `nextRuns`, `isMatch`, `parse`) throw an error for invalid cron expressions. `nextRun`, `previousRun`, `nextRuns`, and `isMatch` also throw for invalid timezones. Use `isValid()` to pre-validate user input, or wrap calls in try/catch. Note: `describe()` returns `"Invalid cron expression"` instead of throwing. 276 277 - **Timezone handling**: The cron expression is interpreted in the timezone you specify, but the returned Date is always in UTC 277 278 - **Daylight saving time**: Use IANA timezone names (like "America/New_York") instead of abbreviations (like "EST") 278 279 - **Day 0 and 7**: Both represent Sunday in the day-of-week field ··· 282 283 283 284 cron-fast is designed for speed and efficiency. Here's how it compares to popular alternatives: 284 285 285 - > Tested with cron-fast v1.0.0, croner v10.0.1, cron-parser v5.5.0, cron-schedule v6.0.0 on Node.js v22.18.0 286 + > Tested with cron-fast v2.0.1, croner v10.0.1, cron-parser v5.5.0, cron-schedule v6.0.0 on Node.js v22.18.0 286 287 287 288 | Operation | cron-fast | croner | cron-parser | cron-schedule | 288 289 | ------------ | -------------- | --------- | ----------- | ------------- | 289 - | Next run | **342k ops/s** | 26k ops/s | 27k ops/s | 353k ops/s | 290 - | Previous run | **371k ops/s** | 30k ops/s | 28k ops/s | 380k ops/s | 291 - | Validation | **544k ops/s** | 33k ops/s | 94k ops/s | 452k ops/s | 292 - | Parsing | **540k ops/s** | 33k ops/s | 96k ops/s | 452k ops/s | 290 + | Next run | **367k ops/s** | 30k ops/s | 33k ops/s | 375k ops/s | 291 + | Previous run | **409k ops/s** | 31k ops/s | 37k ops/s | 386k ops/s | 292 + | Validation | **555k ops/s** | 32k ops/s | 94k ops/s | 436k ops/s | 293 + | Parsing | **543k ops/s** | 32k ops/s | 92k ops/s | 446k ops/s | 293 294 294 295 See [detailed benchmarks and feature comparison](docs/benchmark-comparison.md) (including Deno and Bun runtimes) for more information. 295 296
+77 -19
docs/benchmark-comparison-bun.md
··· 1 1 # Benchmark & Feature Comparison 2 2 3 - > Tested with bun v1.3.9, cron-fast v1.0.0, croner v10.0.1, cron-parser v5.5.0, cron-schedule v6.0.0, cron-validate v1.5.3 3 + > Tested with bun v1.3.9, cron-fast v2.0.1, croner v10.0.1, cron-parser v5.5.0, cron-schedule v6.0.0, cron-validate v1.5.3 4 4 > Tested on MacBook M1 pro 5 5 6 6 ## Performance Benchmarks ··· 11 11 12 12 | Library | Avg ops/sec | vs cron-fast | 13 13 | ------------- | ----------- | ------------ | 14 - | **cron-fast** | ~286k | baseline | 15 - | cron-schedule | ~321k | 1.1x slower | 16 - | croner | ~53k | 5.4x faster | 17 - | cron-parser | ~34k | 8.5x faster | 14 + | **cron-fast** | ~275k | baseline | 15 + | cron-schedule | ~296k | 1.1x slower | 16 + | croner | ~47k | 5.8x faster | 17 + | cron-parser | ~34k | 8.0x faster | 18 18 19 19 ### Previous Execution Time 20 20 21 21 | Library | Avg ops/sec | vs cron-fast | 22 22 | ------------- | ----------- | ------------ | 23 - | **cron-fast** | ~295k | baseline | 24 - | cron-schedule | ~324k | 1.1x slower | 25 - | croner | ~55k | 5.4x faster | 26 - | cron-parser | ~31k | 9.5x faster | 23 + | **cron-fast** | ~313k | baseline | 24 + | cron-schedule | ~323k | 1.0x slower | 25 + | croner | ~56k | 5.5x faster | 26 + | cron-parser | ~40k | 7.8x faster | 27 27 28 28 ### Validation 29 29 30 30 | Library | Avg ops/sec | vs cron-fast | 31 31 | ------------- | ----------- | ------------ | 32 - | **cron-fast** | ~352k | baseline | 33 - | cron-validate | ~946k | 2.7x slower | 34 - | cron-schedule | ~355k | 1.0x slower | 35 - | cron-parser | ~118k | 3.0x faster | 36 - | croner | ~58k | 6.1x faster | 32 + | **cron-fast** | ~360k | baseline | 33 + | cron-validate | ~952k | 2.6x slower | 34 + | cron-schedule | ~357k | 1.0x faster | 35 + | cron-parser | ~120k | 3.0x faster | 36 + | croner | ~60k | 6.0x faster | 37 37 38 38 ### Parsing 39 39 40 40 | Library | Avg ops/sec | vs cron-fast | 41 41 | ------------- | ----------- | ------------ | 42 - | **cron-fast** | ~352k | baseline | 43 - | cron-validate | ~941k | 2.7x slower | 44 - | cron-schedule | ~353k | 1.0x slower | 45 - | cron-parser | ~114k | 3.1x faster | 46 - | croner | ~58k | 6.1x faster | 42 + | **cron-fast** | ~361k | baseline | 43 + | cron-validate | ~925k | 2.6x slower | 44 + | cron-schedule | ~341k | 1.1x faster | 45 + | cron-parser | ~118k | 3.1x faster | 46 + | croner | ~58k | 6.3x faster | 47 47 48 48 **Note**: cron-validate is validation-only (no scheduling), which explains its speed advantage in parsing/validation. It only checks syntax without calculating dates or handling timezones, making it significantly faster for validation-only use cases. 49 49 50 50 Run benchmarks yourself: `pnpm benchmark:bun` 51 + 52 + ## Detailed Per-Test Results 53 + 54 + ### Next Execution - All Libraries 55 + 56 + | Test Case | cron-fast | cron-schedule | croner | cron-parser | 57 + | --------------------------- | --------: | ------------: | -----: | ----------: | 58 + | Every minute | ~193k | ~142k ✓ | ~52k ✓ | ~35k ✓ | 59 + | Sparse: First of month | ~351k | ~391k ✗ | ~52k ✓ | ~21k ✓ | 60 + | Sparse: 31st (skips months) | ~340k | ~385k ✗ | ~31k ✓ | ~7k ✓ | 61 + | Step: Every 15 minutes | ~196k | ~189k | ~51k ✓ | ~56k ✓ | 62 + | Specific: 9 AM daily | ~261k | ~263k | ~52k ✓ | ~42k ✓ | 63 + | OR-mode: 15th OR Monday | ~343k | ~445k ✗ | ~46k ✓ | ~37k ✓ | 64 + | Weekdays: Mon-Fri 9 AM | ~238k | ~256k | ~44k ✓ | ~42k ✓ | 65 + 66 + ✓ = cron-fast is faster (≥10% faster) | ✗ = cron-fast is slower (≥10% slower) 67 + 68 + ### Previous Execution - All Libraries 69 + 70 + | Test Case | cron-fast | cron-schedule | croner | cron-parser | 71 + | --------------------------- | --------: | ------------: | -----: | ----------: | 72 + | Every minute | ~160k | ~155k | ~54k ✓ | ~37k ✓ | 73 + | Sparse: First of month | ~393k | ~423k | ~57k ✓ | ~11k ✓ | 74 + | Sparse: 31st (skips months) | ~380k | ~409k | ~57k ✓ | ~11k ✓ | 75 + | Step: Every 15 minutes | ~186k | ~217k ✗ | ~58k ✓ | ~61k ✓ | 76 + | Specific: 9 AM daily | ~287k | ~290k | ~59k ✓ | ~49k ✓ | 77 + | OR-mode: 15th OR Monday | ~500k | ~489k | ~58k ✓ | ~62k ✓ | 78 + | Weekdays: Mon-Fri 9 AM | ~283k | ~278k | ~53k ✓ | ~51k ✓ | 79 + 80 + ✓ = cron-fast is faster (≥10% faster) | ✗ = cron-fast is slower (≥10% slower) 81 + 82 + ### Validation - All Libraries 83 + 84 + | Test Case | cron-fast | cron-schedule | cron-parser | croner | cron-validate | 85 + | --------------- | --------: | ------------: | ----------: | -----: | ------------: | 86 + | \* \* \* \* \* | ~148k | ~165k ✗ | ~52k ✓ | ~61k ✓ | ~943k ✗ | 87 + | 0 0 1 \* \* | ~512k | ~470k | ~159k ✓ | ~61k ✓ | ~981k ✗ | 88 + | 0 12 31 \* \* | ~467k | ~461k | ~155k ✓ | ~56k ✓ | ~931k ✗ | 89 + | _/15 _ \* \* \* | ~207k | ~228k | ~85k ✓ | ~60k ✓ | ~967k ✗ | 90 + | 0 9 \* \* \* | ~315k | ~305k | ~109k ✓ | ~62k ✓ | ~950k ✗ | 91 + | 0 9 15 \* 1 | ~610k | ~571k | ~177k ✓ | ~61k ✓ | ~936k ✗ | 92 + | 0 9 \* \* 1-5 | ~259k | ~297k ✗ | ~106k ✓ | ~57k ✓ | ~955k ✗ | 93 + 94 + ✓ = cron-fast is faster (≥10% faster) | ✗ = cron-fast is slower (≥10% slower) 95 + 96 + ### Parsing - All Libraries 97 + 98 + | Test Case | cron-fast | cron-schedule | cron-parser | croner | cron-validate | 99 + | --------------- | --------: | ------------: | ----------: | -----: | ------------: | 100 + | \* \* \* \* \* | ~195k | ~164k ✓ | ~52k ✓ | ~63k ✓ | ~945k ✗ | 101 + | 0 0 1 \* \* | ~490k | ~461k | ~160k ✓ | ~62k ✓ | ~966k ✗ | 102 + | 0 12 31 \* \* | ~474k | ~429k ✓ | ~156k ✓ | ~60k ✓ | ~901k ✗ | 103 + | _/15 _ \* \* \* | ~221k | ~207k | ~81k ✓ | ~54k ✓ | ~945k ✗ | 104 + | 0 9 \* \* \* | ~304k | ~287k | ~104k ✓ | ~56k ✓ | ~896k ✗ | 105 + | 0 9 15 \* 1 | ~570k | ~543k | ~166k ✓ | ~54k ✓ | ~901k ✗ | 106 + | 0 9 \* \* 1-5 | ~275k | ~295k | ~107k ✓ | ~54k ✓ | ~920k ✗ | 107 + 108 + ✓ = cron-fast is faster (≥10% faster) | ✗ = cron-fast is slower (≥10% slower)
+77 -19
docs/benchmark-comparison-deno.md
··· 1 1 # Benchmark & Feature Comparison 2 2 3 - > Tested with deno v2.6.8, cron-fast v1.0.0, croner v10.0.1, cron-parser v5.5.0, cron-schedule v6.0.0, cron-validate v1.5.3 3 + > Tested with deno v2.6.8, cron-fast v2.0.1, croner v10.0.1, cron-parser v5.5.0, cron-schedule v6.0.0, cron-validate v1.5.3 4 4 > Tested on MacBook M1 pro 5 5 6 6 ## Performance Benchmarks ··· 11 11 12 12 | Library | Avg ops/sec | vs cron-fast | 13 13 | ------------- | ----------- | ------------ | 14 - | **cron-fast** | ~296k | baseline | 15 - | cron-schedule | ~373k | 1.3x slower | 16 - | croner | ~25k | 12.0x faster | 17 - | cron-parser | ~24k | 12.3x faster | 14 + | **cron-fast** | ~362k | baseline | 15 + | cron-schedule | ~399k | 1.1x slower | 16 + | croner | ~31k | 11.7x faster | 17 + | cron-parser | ~33k | 10.8x faster | 18 18 19 19 ### Previous Execution Time 20 20 21 21 | Library | Avg ops/sec | vs cron-fast | 22 22 | ------------- | ----------- | ------------ | 23 - | **cron-fast** | ~364k | baseline | 24 - | cron-schedule | ~409k | 1.1x slower | 25 - | croner | ~29k | 12.4x faster | 26 - | cron-parser | ~26k | 14.0x faster | 23 + | **cron-fast** | ~412k | baseline | 24 + | cron-schedule | ~419k | 1.0x slower | 25 + | croner | ~31k | 13.3x faster | 26 + | cron-parser | ~39k | 10.6x faster | 27 27 28 28 ### Validation 29 29 30 30 | Library | Avg ops/sec | vs cron-fast | 31 31 | ------------- | ----------- | ------------ | 32 - | **cron-fast** | ~498k | baseline | 33 - | cron-validate | ~588k | 1.2x slower | 34 - | cron-schedule | ~447k | 1.1x faster | 35 - | cron-parser | ~89k | 5.6x faster | 36 - | croner | ~29k | 17.3x faster | 32 + | **cron-fast** | ~550k | baseline | 33 + | cron-validate | ~635k | 1.2x slower | 34 + | cron-schedule | ~478k | 1.2x faster | 35 + | cron-parser | ~98k | 5.6x faster | 36 + | croner | ~34k | 16.4x faster | 37 37 38 38 ### Parsing 39 39 40 40 | Library | Avg ops/sec | vs cron-fast | 41 41 | ------------- | ----------- | ------------ | 42 - | **cron-fast** | ~522k | baseline | 43 - | cron-validate | ~628k | 1.2x slower | 44 - | cron-schedule | ~479k | 1.1x faster | 45 - | cron-parser | ~89k | 5.9x faster | 46 - | croner | ~32k | 16.5x faster | 42 + | **cron-fast** | ~549k | baseline | 43 + | cron-validate | ~628k | 1.1x slower | 44 + | cron-schedule | ~472k | 1.2x faster | 45 + | cron-parser | ~97k | 5.7x faster | 46 + | croner | ~34k | 16.4x faster | 47 47 48 48 **Note**: cron-validate is validation-only (no scheduling), which explains its speed advantage in parsing/validation. It only checks syntax without calculating dates or handling timezones, making it significantly faster for validation-only use cases. 49 49 50 50 Run benchmarks yourself: `pnpm benchmark:deno` 51 + 52 + ## Detailed Per-Test Results 53 + 54 + ### Next Execution - All Libraries 55 + 56 + | Test Case | cron-fast | cron-schedule | croner | cron-parser | 57 + | --------------------------- | --------: | ------------: | -----: | ----------: | 58 + | Every minute | ~217k | ~145k ✓ | ~32k ✓ | ~31k ✓ | 59 + | Sparse: First of month | ~459k | ~522k ✗ | ~31k ✓ | ~18k ✓ | 60 + | Sparse: 31st (skips months) | ~434k | ~539k ✗ | ~30k ✓ | ~7k ✓ | 61 + | Step: Every 15 minutes | ~289k | ~277k | ~33k ✓ | ~56k ✓ | 62 + | Specific: 9 AM daily | ~366k | ~361k | ~33k ✓ | ~42k ✓ | 63 + | OR-mode: 15th OR Monday | ~403k | ~590k ✗ | ~29k ✓ | ~37k ✓ | 64 + | Weekdays: Mon-Fri 9 AM | ~368k | ~355k | ~28k ✓ | ~44k ✓ | 65 + 66 + ✓ = cron-fast is faster (≥10% faster) | ✗ = cron-fast is slower (≥10% slower) 67 + 68 + ### Previous Execution - All Libraries 69 + 70 + | Test Case | cron-fast | cron-schedule | croner | cron-parser | 71 + | --------------------------- | --------: | ------------: | -----: | ----------: | 72 + | Every minute | ~221k | ~187k ✓ | ~31k ✓ | ~35k ✓ | 73 + | Sparse: First of month | ~513k | ~562k | ~31k ✓ | ~9k ✓ | 74 + | Sparse: 31st (skips months) | ~438k | ~510k ✗ | ~31k ✓ | ~8k ✓ | 75 + | Step: Every 15 minutes | ~288k | ~271k | ~32k ✓ | ~57k ✓ | 76 + | Specific: 9 AM daily | ~379k | ~381k | ~31k ✓ | ~50k ✓ | 77 + | OR-mode: 15th OR Monday | ~664k | ~656k | ~31k ✓ | ~63k ✓ | 78 + | Weekdays: Mon-Fri 9 AM | ~382k | ~366k | ~30k ✓ | ~49k ✓ | 79 + 80 + ✓ = cron-fast is faster (≥10% faster) | ✗ = cron-fast is slower (≥10% slower) 81 + 82 + ### Validation - All Libraries 83 + 84 + | Test Case | cron-fast | cron-schedule | cron-parser | croner | cron-validate | 85 + | --------------- | --------: | ------------: | ----------: | -----: | ------------: | 86 + | \* \* \* \* \* | ~237k | ~197k ✓ | ~45k ✓ | ~34k ✓ | ~600k ✗ | 87 + | 0 0 1 \* \* | ~717k | ~633k ✓ | ~126k ✓ | ~34k ✓ | ~642k ✓ | 88 + | 0 12 31 \* \* | ~671k | ~628k | ~126k ✓ | ~34k ✓ | ~641k | 89 + | _/15 _ \* \* \* | ~336k | ~290k ✓ | ~68k ✓ | ~34k ✓ | ~678k ✗ | 90 + | 0 9 \* \* \* | ~474k | ~407k ✓ | ~88k ✓ | ~34k ✓ | ~616k ✗ | 91 + | 0 9 15 \* 1 | ~956k | ~780k ✓ | ~140k ✓ | ~32k ✓ | ~654k ✓ | 92 + | 0 9 \* \* 1-5 | ~460k | ~410k ✓ | ~90k ✓ | ~32k ✓ | ~616k ✗ | 93 + 94 + ✓ = cron-fast is faster (≥10% faster) | ✗ = cron-fast is slower (≥10% slower) 95 + 96 + ### Parsing - All Libraries 97 + 98 + | Test Case | cron-fast | cron-schedule | cron-parser | croner | cron-validate | 99 + | --------------- | --------: | ------------: | ----------: | -----: | ------------: | 100 + | \* \* \* \* \* | ~236k | ~200k ✓ | ~46k ✓ | ~34k ✓ | ~598k ✗ | 101 + | 0 0 1 \* \* | ~723k | ~626k ✓ | ~127k ✓ | ~34k ✓ | ~642k ✓ | 102 + | 0 12 31 \* \* | ~664k | ~614k | ~122k ✓ | ~33k ✓ | ~628k | 103 + | _/15 _ \* \* \* | ~328k | ~288k ✓ | ~68k ✓ | ~34k ✓ | ~655k ✗ | 104 + | 0 9 \* \* \* | ~470k | ~407k ✓ | ~88k ✓ | ~34k ✓ | ~615k ✗ | 105 + | 0 9 15 \* 1 | ~968k | ~761k ✓ | ~138k ✓ | ~32k ✓ | ~651k ✓ | 106 + | 0 9 \* \* 1-5 | ~457k | ~405k ✓ | ~88k ✓ | ~33k ✓ | ~609k ✗ | 107 + 108 + ✓ = cron-fast is faster (≥10% faster) | ✗ = cron-fast is slower (≥10% slower)
+76 -18
docs/benchmark-comparison-node.md
··· 1 1 # Benchmark & Feature Comparison 2 2 3 - > Tested with node v22.18.0, cron-fast v1.0.0, croner v10.0.1, cron-parser v5.5.0, cron-schedule v6.0.0, cron-validate v1.5.3 3 + > Tested with node v22.18.0, cron-fast v2.0.1, croner v10.0.1, cron-parser v5.5.0, cron-schedule v6.0.0, cron-validate v1.5.3 4 4 > Tested on MacBook M1 pro 5 5 6 6 ## Performance Benchmarks ··· 11 11 12 12 | Library | Avg ops/sec | vs cron-fast | 13 13 | ------------- | ----------- | ------------ | 14 - | **cron-fast** | ~342k | baseline | 15 - | cron-schedule | ~353k | 1.0x slower | 16 - | croner | ~26k | 13.0x faster | 17 - | cron-parser | ~27k | 12.5x faster | 14 + | **cron-fast** | ~367k | baseline | 15 + | cron-schedule | ~375k | 1.0x slower | 16 + | croner | ~30k | 12.2x faster | 17 + | cron-parser | ~33k | 11.1x faster | 18 18 19 19 ### Previous Execution Time 20 20 21 21 | Library | Avg ops/sec | vs cron-fast | 22 22 | ------------- | ----------- | ------------ | 23 - | **cron-fast** | ~371k | baseline | 24 - | cron-schedule | ~380k | 1.0x slower | 25 - | croner | ~30k | 12.3x faster | 26 - | cron-parser | ~28k | 13.1x faster | 23 + | **cron-fast** | ~409k | baseline | 24 + | cron-schedule | ~386k | 1.1x faster | 25 + | croner | ~31k | 13.4x faster | 26 + | cron-parser | ~37k | 11.0x faster | 27 27 28 28 ### Validation 29 29 30 30 | Library | Avg ops/sec | vs cron-fast | 31 31 | ------------- | ----------- | ------------ | 32 - | **cron-fast** | ~544k | baseline | 33 - | cron-validate | ~654k | 1.2x slower | 34 - | cron-schedule | ~452k | 1.2x faster | 35 - | cron-parser | ~94k | 5.8x faster | 36 - | croner | ~33k | 16.7x faster | 32 + | **cron-fast** | ~555k | baseline | 33 + | cron-validate | ~664k | 1.2x slower | 34 + | cron-schedule | ~436k | 1.3x faster | 35 + | cron-parser | ~94k | 5.9x faster | 36 + | croner | ~32k | 17.2x faster | 37 37 38 38 ### Parsing 39 39 40 40 | Library | Avg ops/sec | vs cron-fast | 41 41 | ------------- | ----------- | ------------ | 42 - | **cron-fast** | ~540k | baseline | 42 + | **cron-fast** | ~543k | baseline | 43 43 | cron-validate | ~659k | 1.2x slower | 44 - | cron-schedule | ~452k | 1.2x faster | 45 - | cron-parser | ~96k | 5.6x faster | 46 - | croner | ~33k | 16.4x faster | 44 + | cron-schedule | ~446k | 1.2x faster | 45 + | cron-parser | ~92k | 5.9x faster | 46 + | croner | ~32k | 17.2x faster | 47 47 48 48 **Note**: cron-validate is validation-only (no scheduling), which explains its speed advantage in parsing/validation. It only checks syntax without calculating dates or handling timezones, making it significantly faster for validation-only use cases. 49 49 50 50 Run benchmarks yourself: `pnpm benchmark` 51 + 52 + ## Detailed Per-Test Results 53 + 54 + ### Next Execution - All Libraries 55 + 56 + | Test Case | cron-fast | cron-schedule | croner | cron-parser | 57 + | --------------------------- | --------: | ------------: | -----: | ----------: | 58 + | Every minute | ~210k | ~172k ✓ | ~30k ✓ | ~30k ✓ | 59 + | Sparse: First of month | ~463k | ~482k | ~29k ✓ | ~18k ✓ | 60 + | Sparse: 31st (skips months) | ~461k | ~509k | ~28k ✓ | ~7k ✓ | 61 + | Step: Every 15 minutes | ~284k | ~262k | ~33k ✓ | ~55k ✓ | 62 + | Specific: 9 AM daily | ~378k | ~343k ✓ | ~33k ✓ | ~43k ✓ | 63 + | OR-mode: 15th OR Monday | ~402k | ~536k ✗ | ~29k ✓ | ~36k ✓ | 64 + | Weekdays: Mon-Fri 9 AM | ~369k | ~321k ✓ | ~29k ✓ | ~43k ✓ | 65 + 66 + ✓ = cron-fast is faster (≥10% faster) | ✗ = cron-fast is slower (≥10% slower) 67 + 68 + ### Previous Execution - All Libraries 69 + 70 + | Test Case | cron-fast | cron-schedule | croner | cron-parser | 71 + | --------------------------- | --------: | ------------: | -----: | ----------: | 72 + | Every minute | ~216k | ~175k ✓ | ~31k ✓ | ~34k ✓ | 73 + | Sparse: First of month | ~522k | ~519k | ~30k ✓ | ~8k ✓ | 74 + | Sparse: 31st (skips months) | ~448k | ~470k | ~30k ✓ | ~8k ✓ | 75 + | Step: Every 15 minutes | ~286k | ~260k | ~32k ✓ | ~55k ✓ | 76 + | Specific: 9 AM daily | ~356k | ~347k | ~31k ✓ | ~48k ✓ | 77 + | OR-mode: 15th OR Monday | ~663k | ~592k ✓ | ~30k ✓ | ~59k ✓ | 78 + | Weekdays: Mon-Fri 9 AM | ~371k | ~339k | ~30k ✓ | ~48k ✓ | 79 + 80 + ✓ = cron-fast is faster (≥10% faster) | ✗ = cron-fast is slower (≥10% slower) 81 + 82 + ### Validation - All Libraries 83 + 84 + | Test Case | cron-fast | cron-schedule | cron-parser | croner | cron-validate | 85 + | --------------- | --------: | ------------: | ----------: | -----: | ------------: | 86 + | \* \* \* \* \* | ~233k | ~190k ✓ | ~45k ✓ | ~33k ✓ | ~648k ✗ | 87 + | 0 0 1 \* \* | ~759k | ~588k ✓ | ~124k ✓ | ~33k ✓ | ~674k ✓ | 88 + | 0 12 31 \* \* | ~689k | ~582k ✓ | ~124k ✓ | ~33k ✓ | ~659k | 89 + | _/15 _ \* \* \* | ~316k | ~265k ✓ | ~65k ✓ | ~32k ✓ | ~700k ✗ | 90 + | 0 9 \* \* \* | ~461k | ~369k ✓ | ~84k ✓ | ~32k ✓ | ~635k ✗ | 91 + | 0 9 15 \* 1 | ~979k | ~690k ✓ | ~131k ✓ | ~32k ✓ | ~686k ✓ | 92 + | 0 9 \* \* 1-5 | ~445k | ~368k ✓ | ~84k ✓ | ~30k ✓ | ~649k ✗ | 93 + 94 + ✓ = cron-fast is faster (≥10% faster) | ✗ = cron-fast is slower (≥10% slower) 95 + 96 + ### Parsing - All Libraries 97 + 98 + | Test Case | cron-fast | cron-schedule | cron-parser | croner | cron-validate | 99 + | --------------- | --------: | ------------: | ----------: | -----: | ------------: | 100 + | \* \* \* \* \* | ~223k | ~184k ✓ | ~45k ✓ | ~32k ✓ | ~606k ✗ | 101 + | 0 0 1 \* \* | ~721k | ~586k ✓ | ~119k ✓ | ~32k ✓ | ~673k | 102 + | 0 12 31 \* \* | ~668k | ~592k ✓ | ~118k ✓ | ~31k ✓ | ~655k | 103 + | _/15 _ \* \* \* | ~319k | ~271k ✓ | ~65k ✓ | ~32k ✓ | ~708k ✗ | 104 + | 0 9 \* \* \* | ~458k | ~372k ✓ | ~83k ✓ | ~32k ✓ | ~639k ✗ | 105 + | 0 9 15 \* 1 | ~966k | ~748k ✓ | ~126k ✓ | ~31k ✓ | ~694k ✓ | 106 + | 0 9 \* \* 1-5 | ~448k | ~371k ✓ | ~86k ✓ | ~31k ✓ | ~637k ✗ | 107 + 108 + ✓ = cron-fast is faster (≥10% faster) | ✗ = cron-fast is slower (≥10% slower)
+1 -1
jsr.json
··· 1 1 { 2 2 "name": "@kbilkis/cron-fast", 3 - "version": "2.0.0", 3 + "version": "2.0.1", 4 4 "description": "Fast and tiny JavaScript/TypeScript cron parser with timezone support - works in Node.js, Deno, Bun, Cloudflare Workers, and browsers. Zero dependencies.", 5 5 "keywords": [ 6 6 "javascript",
+2 -2
package.json
··· 1 1 { 2 2 "name": "cron-fast", 3 - "version": "2.0.0", 3 + "version": "2.0.1", 4 4 "description": "Fast and tiny JavaScript/TypeScript cron parser with timezone support - works in Node.js, Deno, Bun, Cloudflare Workers, and browsers. Zero dependencies.", 5 5 "keywords": [ 6 6 "browser", ··· 64 64 "lint:fix": "oxlint --fix", 65 65 "fmt": "oxfmt", 66 66 "fmt:check": "oxfmt --check", 67 - "typecheck": "tsc --noEmit", 67 + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json", 68 68 "benchmark": "tsx scripts/benchmark.ts", 69 69 "benchmark:deno": "deno run --allow-read --allow-env --allow-write --unstable-sloppy-imports scripts/benchmark.ts", 70 70 "benchmark:bun": "bun run scripts/benchmark.ts",
+102 -26
scripts/benchmark.ts
··· 103 103 104 104 const testCases = [ 105 105 { 106 - name: "Next minute", 106 + name: "Every minute", 107 107 cron: "* * * * *", 108 108 from: new Date("2026-01-15T10:30:00Z"), 109 109 }, 110 110 { 111 - name: "Specific time today", 112 - cron: "0 15 * * *", 113 - from: new Date("2026-01-15T10:30:00Z"), 114 - }, 115 - { 116 - name: "Next day at midnight", 117 - cron: "0 0 * * *", 118 - from: new Date("2026-01-15T23:30:00Z"), 119 - }, 120 - { 121 - name: "Next Monday", 122 - cron: "0 9 * * 1", 123 - from: new Date("2026-01-15T10:00:00Z"), 124 - }, 125 - { 126 - name: "First of next month", 111 + name: "Sparse: First of month", 127 112 cron: "0 0 1 * *", 128 113 from: new Date("2026-01-15T10:00:00Z"), 129 114 }, 130 115 { 131 - name: "31st of month (skips Feb)", 116 + name: "Sparse: 31st (skips months)", 132 117 cron: "0 12 31 * *", 133 118 from: new Date("2026-02-15T10:00:00Z"), 134 119 }, 120 + // Step values: cron-fast should jump 135 121 { 136 - name: "Every 15 minutes", 122 + name: "Step: Every 15 minutes", 137 123 cron: "*/15 * * * *", 138 124 from: new Date("2026-01-15T10:07:00Z"), 139 125 }, 126 + // Specific times: direct jumps 140 127 { 141 - name: "Christmas at noon", 142 - cron: "0 12 25 12 *", 128 + name: "Specific: 9 AM daily", 129 + cron: "0 9 * * *", 130 + from: new Date("2026-01-15T10:30:00Z"), 131 + }, 132 + // OR-mode: potentially slower for cron-fast 133 + { 134 + name: "OR-mode: 15th OR Monday", 135 + cron: "0 9 15 * 1", 136 + from: new Date("2026-01-15T10:00:00Z"), 137 + }, 138 + // Weekday only 139 + { 140 + name: "Weekdays: Mon-Fri 9 AM", 141 + cron: "0 9 * * 1-5", 143 142 from: new Date("2026-01-15T10:00:00Z"), 144 143 }, 145 144 ]; ··· 381 380 const libAvg = Math.round( 382 381 libResults.reduce((sum, r) => sum + r.opsPerSecond, 0) / libResults.length, 383 382 ); 384 - const speedup = (cronFastAvg / libAvg).toFixed(1); 385 - console.log( 386 - ` ${lib.padEnd(15)}: ${speedup}x ${cronFastAvg > libAvg ? "faster" : "slower"}`, 387 - ); 383 + const ratio = cronFastAvg / libAvg; 384 + const speedup = 385 + ratio >= 1 ? `${ratio.toFixed(1)}x faster` : `${(libAvg / cronFastAvg).toFixed(1)}x slower`; 386 + console.log(` ${lib.padEnd(15)}: ${speedup}`); 388 387 } 389 388 }); 390 389 } ··· 451 450 // Helper to calculate speedup 452 451 const calcSpeedup = (cronFastAvg: number, libAvg: number) => { 453 452 const ratio = cronFastAvg / libAvg; 454 - return ratio > 1 ? `${ratio.toFixed(1)}x faster` : `${(1 / ratio).toFixed(1)}x slower`; 453 + return ratio >= 1 454 + ? `${ratio.toFixed(1)}x faster` 455 + : `${(libAvg / cronFastAvg).toFixed(1)}x slower`; 456 + }; 457 + 458 + // Helper to build detailed per-test table 459 + const buildDetailedTable = ( 460 + results: BenchmarkResult[], 461 + testNames: string[], 462 + libraries: string[], 463 + ) => { 464 + const header = `| Test Case | ${libraries.join(" | ")} |`; 465 + const separator = `| --- | ${libraries.map(() => "---:").join(" | ")} |`; 466 + const rows = testNames.map((testName) => { 467 + const values = libraries.map((lib) => { 468 + const result = results.find((r) => r.name === testName && r.library === lib); 469 + if (!result) return "N/A"; 470 + const ops = formatOps(result.opsPerSecond); 471 + 472 + // Add comparison indicator vs cron-fast 473 + if (lib !== "cron-fast") { 474 + const cronFastResult = results.find( 475 + (r) => r.name === testName && r.library === "cron-fast", 476 + ); 477 + if (cronFastResult) { 478 + const ratio = cronFastResult.opsPerSecond / result.opsPerSecond; 479 + if (ratio >= 1.1) { 480 + return `${ops} ✓`; // cron-fast is faster 481 + } else if (ratio <= 0.9) { 482 + return `${ops} ✗`; // cron-fast is slower 483 + } 484 + } 485 + } 486 + return ops; 487 + }); 488 + return `| ${testName} | ${values.join(" | ")} |`; 489 + }); 490 + return [header, separator, ...rows].join("\n"); 455 491 }; 456 492 457 493 // Calculate averages for each benchmark ··· 538 574 /### Parsing\n\n\| Library.*?\n\| -.*?\n(?:\| .*?\n)+/s, 539 575 `### Parsing\n\n${parseTable}\n`, 540 576 ); 577 + 578 + // Add detailed per-test tables at the end 579 + const testNames = testCases.map((tc) => tc.name); 580 + const validationTestNames = testCases.map((tc) => tc.cron); 581 + 582 + const detailedSection = ` 583 + 584 + ## Detailed Per-Test Results 585 + 586 + ### Next Execution - All Libraries 587 + 588 + ${buildDetailedTable(results, testNames, ["cron-fast", "cron-schedule", "croner", "cron-parser"])} 589 + 590 + ✓ = cron-fast is faster (≥10% faster) | ✗ = cron-fast is slower (≥10% slower) 591 + 592 + ### Previous Execution - All Libraries 593 + 594 + ${buildDetailedTable(prevResults, testNames, ["cron-fast", "cron-schedule", "croner", "cron-parser"])} 595 + 596 + ✓ = cron-fast is faster (≥10% faster) | ✗ = cron-fast is slower (≥10% slower) 597 + 598 + ### Validation - All Libraries 599 + 600 + ${buildDetailedTable(validationResults, validationTestNames, ["cron-fast", "cron-schedule", "cron-parser", "croner", "cron-validate"])} 601 + 602 + ✓ = cron-fast is faster (≥10% faster) | ✗ = cron-fast is slower (≥10% slower) 603 + 604 + ### Parsing - All Libraries 605 + 606 + ${buildDetailedTable(parseResults, validationTestNames, ["cron-fast", "cron-schedule", "cron-parser", "croner", "cron-validate"])} 607 + 608 + ✓ = cron-fast is faster (≥10% faster) | ✗ = cron-fast is slower (≥10% slower) 609 + `; 610 + 611 + // Replace or append detailed section 612 + if (doc.includes("## Detailed Per-Test Results")) { 613 + doc = doc.replace(/## Detailed Per-Test Results[\s\S]*$/, detailedSection.trim()); 614 + } else { 615 + doc += "\n" + detailedSection; 616 + } 541 617 542 618 writeFileSync(docPath, doc, "utf-8"); 543 619 console.log(` ✓ Updated docs/benchmark-comparison-${runtime.name}.md`);
+7 -3
src/describe.ts
··· 30 30 31 31 /** 32 32 * Generate a human-readable description of a cron expression. 33 - * Returns empty string if the expression is invalid. 33 + * Returns "Invalid cron expression" if the expression is invalid. 34 34 */ 35 35 export function describe(expression: string): string { 36 - const parsed = parse(expression); 37 - if (!parsed) return ""; 36 + let parsed: ParsedCron; 37 + try { 38 + parsed = parse(expression); 39 + } catch { 40 + return "Invalid cron expression"; 41 + } 38 42 39 43 const parts: string[] = []; 40 44
+30 -10
src/parser.ts
··· 37 37 * 38 38 * Note: Months are converted from cron's 1-indexed format (1-12) to 39 39 * JavaScript's 0-indexed format (0-11) for internal consistency. 40 + * 41 + * @throws {Error} If the expression is invalid 40 42 */ 41 - export function parse(expression: string): ParsedCron | null { 43 + export function parse(expression: string): ParsedCron { 42 44 const trimmed = expression.trim(); 43 - if (!trimmed) return null; 45 + if (!trimmed) throw new Error(`Invalid cron expression: "${expression}"`); 44 46 45 47 const parts = trimmed.split(/\s+/); 46 - if (parts.length !== 5) return null; 48 + if (parts.length !== 5) 49 + throw new Error( 50 + `Invalid cron expression: "${expression}" - expected 5 fields, got ${parts.length}`, 51 + ); 47 52 48 53 const [minuteStr, hourStr, dayStr, monthStr, weekdayStr] = parts; 49 54 50 55 const minute = parseField(minuteStr, 0, 59); 51 - if (!minute) return null; 56 + if (!minute) 57 + throw new Error( 58 + `Invalid cron expression: "${expression}" - invalid minute field "${minuteStr}"`, 59 + ); 52 60 53 61 const hour = parseField(hourStr, 0, 23); 54 - if (!hour) return null; 62 + if (!hour) 63 + throw new Error(`Invalid cron expression: "${expression}" - invalid hour field "${hourStr}"`); 55 64 56 65 const day = parseField(dayStr, 1, 31); 57 - if (!day) return null; 66 + if (!day) 67 + throw new Error(`Invalid cron expression: "${expression}" - invalid day field "${dayStr}"`); 58 68 59 69 const month = parseField(monthStr, 1, 12, MONTH_NAMES); 60 - if (!month) return null; 70 + if (!month) 71 + throw new Error(`Invalid cron expression: "${expression}" - invalid month field "${monthStr}"`); 61 72 62 73 const weekdayRaw = parseField(weekdayStr, 0, 7, WEEKDAY_NAMES); 63 - if (!weekdayRaw) return null; 74 + if (!weekdayRaw) 75 + throw new Error( 76 + `Invalid cron expression: "${expression}" - invalid weekday field "${weekdayStr}"`, 77 + ); 64 78 65 79 const weekdays = weekdayRaw.map((d) => (d === 7 ? 0 : d)); 66 80 ··· 74 88 weekdayIsWildcard: weekdayStr.trim() === "*", 75 89 }; 76 90 77 - if (!hasValidDayMonthCombinations(parsed)) return null; 91 + if (!hasValidDayMonthCombinations(parsed)) 92 + throw new Error(`Invalid cron expression: "${expression}" - impossible day/month combination`); 78 93 79 94 return parsed; 80 95 } ··· 188 203 189 204 /** Validate a cron expression */ 190 205 export function isValid(expression: string): boolean { 191 - return parse(expression) !== null; 206 + try { 207 + parse(expression); 208 + return true; 209 + } catch { 210 + return false; 211 + } 192 212 }
+1 -10
src/scheduler.ts
··· 34 34 /** Get the next execution time for a cron expression. Throws if expression or timezone is invalid, or if no match is found within iteration limit. */ 35 35 export function nextRun(expression: string, options?: CronOptions): Date { 36 36 const parsed = parse(expression); 37 - if (!parsed) throw new Error(`Invalid cron expression: "${expression}"`); 38 37 39 38 const from = options?.from || new Date(); 40 39 const tz = options?.timezone; 41 40 42 41 const start = tz !== undefined ? convertToTimezone(from, tz) : new Date(from); 43 - if (!start) throw new Error(`Invalid timezone: "${tz}"`); 44 - 45 42 start.setUTCSeconds(0, 0); 46 43 start.setUTCMinutes(start.getUTCMinutes() + 1); 47 44 ··· 51 48 /** Get the previous execution time for a cron expression. Throws if expression or timezone is invalid, or if no match is found within iteration limit. */ 52 49 export function previousRun(expression: string, options?: CronOptions): Date { 53 50 const parsed = parse(expression); 54 - if (!parsed) throw new Error(`Invalid cron expression: "${expression}"`); 55 51 56 52 const from = options?.from || new Date(); 57 53 const tz = options?.timezone; 58 54 59 55 const start = tz !== undefined ? convertToTimezone(from, tz) : new Date(from); 60 - if (!start) throw new Error(`Invalid timezone: "${tz}"`); 61 - 62 56 start.setUTCSeconds(0, 0); 63 57 start.setUTCMinutes(start.getUTCMinutes() - 1); 64 58 ··· 87 81 options?: Pick<CronOptions, "timezone">, 88 82 ): boolean { 89 83 const parsed = parse(expression); 90 - if (!parsed) throw new Error(`Invalid cron expression: "${expression}"`); 91 84 92 85 const tz = options?.timezone; 93 86 const checkDate = tz !== undefined ? convertToTimezone(date, tz) : new Date(date); 94 - 95 - if (!checkDate) throw new Error(`Invalid timezone: "${tz}"`); 96 87 return matches(parsed, checkDate); 97 88 } 98 89 ··· 108 99 109 100 for (let i = 0; i < MAX_ITERATIONS; i++) { 110 101 if (matches(parsed, current)) { 111 - return tz !== undefined ? convertFromTimezone(current, tz)! : current; 102 + return tz !== undefined ? convertFromTimezone(current, tz) : current; 112 103 } 113 104 advanceDate(parsed, current, dir); 114 105 }
+9 -6
src/timezone.ts
··· 8 8 } 9 9 } 10 10 11 - /** Convert a UTC date to wall-clock time in the target timezone. Returns null if timezone is invalid. */ 12 - export function convertToTimezone(date: Date, timezone: string): Date | null { 13 - if (!isValidTimezone(timezone)) return null; 11 + /** 12 + * Convert a UTC date to wall-clock time in the target timezone. 13 + * @throws {Error} If timezone is invalid 14 + */ 15 + export function convertToTimezone(date: Date, timezone: string): Date { 16 + if (!isValidTimezone(timezone)) throw new Error(`Invalid timezone: "${timezone}"`); 14 17 15 18 const str = date.toLocaleString("en-US", { 16 19 timeZone: timezone, ··· 35 38 36 39 /** 37 40 * Convert a timezone-local date back to UTC (inverse of convertToTimezone). 38 - * Returns null if timezone is invalid. 41 + * @throws {Error} If timezone is invalid 39 42 * 40 43 * Note: During DST fall-back, multiple UTC times map to the same wall-clock time. 41 44 * The result is implementation-defined. Avoid scheduling during DST transition hours 42 45 * for predictable behavior. 43 46 */ 44 - export function convertFromTimezone(date: Date, timezone: string): Date | null { 45 - if (!isValidTimezone(timezone)) return null; 47 + export function convertFromTimezone(date: Date, timezone: string): Date { 48 + if (!isValidTimezone(timezone)) throw new Error(`Invalid timezone: "${timezone}"`); 46 49 47 50 // Target time as a comparable number (for checking if we found it) 48 51 const targetTime = Date.UTC(
+7 -7
test/describe.test.ts
··· 374 374 expect(describeCron("0 0 29 2 *")).toBe("At 12:00 AM, on the 29th in February"); 375 375 }); 376 376 377 - it("should return empty string for impossible date combinations", () => { 378 - expect(describeCron("0 0 31 2 *")).toBe(""); 379 - expect(describeCron("0 0 30 2 *")).toBe(""); 380 - expect(describeCron("0 0 31 4 *")).toBe(""); 381 - expect(describeCron("0 0 31 6 *")).toBe(""); 382 - expect(describeCron("0 0 31 9 *")).toBe(""); 383 - expect(describeCron("0 0 31 11 *")).toBe(""); 377 + it("should return error message for impossible date combinations", () => { 378 + expect(describeCron("0 0 31 2 *")).toBe("Invalid cron expression"); 379 + expect(describeCron("0 0 30 2 *")).toBe("Invalid cron expression"); 380 + expect(describeCron("0 0 31 4 *")).toBe("Invalid cron expression"); 381 + expect(describeCron("0 0 31 6 *")).toBe("Invalid cron expression"); 382 + expect(describeCron("0 0 31 9 *")).toBe("Invalid cron expression"); 383 + expect(describeCron("0 0 31 11 *")).toBe("Invalid cron expression"); 384 384 }); 385 385 386 386 // ==========================================================================
+3 -6
test/matcher.test.ts
··· 846 846 }); 847 847 848 848 it("should return false when day uses step but is still effectively wildcard", () => { 849 - // */1 is effectively a wildcard but parser treats it as non-wildcard 850 - // Note: The parser checks dayStr.trim() === "*" for wildcard detection 851 - // So */1 would NOT be treated as a wildcard 849 + // */1 matches all days (1-31) but parser checks dayStr === "*" strictly, 850 + // so dayIsWildcard is false. However weekday IS wildcard, so isOrMode is false. 852 851 const parsed = parse("0 0 */1 * *"); 853 - // This is actually NOT a wildcard because the check is strict === "*" 854 - // But let's verify the actual behavior 855 - // Note: This test documents current behavior 852 + expect(isOrMode(parsed)).toBe(false); 856 853 }); 857 854 }); 858 855
+67 -67
test/parser.test.ts
··· 288 288 it("should NOT support full month names (only 3-letter codes)", () => { 289 289 // The parser only supports 3-letter codes: jan, feb, mar, etc. 290 290 // Full names like JANUARY are not supported 291 - expect(parse("0 0 1 JANUARY *")).toBeNull(); 291 + expect(() => parse("0 0 1 JANUARY *")).toThrow(); 292 292 }); 293 293 294 294 it("should mix month names and numbers in comma-separated list", () => { ··· 346 346 347 347 describe("error cases - empty and malformed input", () => { 348 348 it("should return null for empty expression", () => { 349 - expect(parse("")).toBeNull(); 349 + expect(() => parse("")).toThrow(); 350 350 }); 351 351 352 352 it("should return null for whitespace-only expression", () => { 353 - expect(parse(" ")).toBeNull(); 353 + expect(() => parse(" ")).toThrow(); 354 354 }); 355 355 356 356 it("should return null for tab-only expression", () => { 357 - expect(parse("\t\t\t")).toBeNull(); 357 + expect(() => parse("\t\t\t")).toThrow(); 358 358 }); 359 359 360 360 it("should return null for newline-only expression", () => { 361 - expect(parse("\n\n")).toBeNull(); 361 + expect(() => parse("\n\n")).toThrow(); 362 362 }); 363 363 364 364 it("should return null for mixed whitespace-only expression", () => { 365 - expect(parse(" \t\n ")).toBeNull(); 365 + expect(() => parse(" \t\n ")).toThrow(); 366 366 }); 367 367 368 368 it("should return null for wrong number of fields - too few", () => { 369 - expect(parse("* * *")).toBeNull(); 369 + expect(() => parse("* * *")).toThrow(); 370 370 }); 371 371 372 372 it("should return null for wrong number of fields - too many", () => { 373 - expect(parse("* * * * * *")).toBeNull(); 373 + expect(() => parse("* * * * * *")).toThrow(); 374 374 }); 375 375 376 376 it("should return null for single field", () => { 377 - expect(parse("*")).toBeNull(); 377 + expect(() => parse("*")).toThrow(); 378 378 }); 379 379 380 380 it("should return null for four fields", () => { 381 - expect(parse("* * * *")).toBeNull(); 381 + expect(() => parse("* * * *")).toThrow(); 382 382 }); 383 383 }); 384 384 385 385 describe("error cases - step values", () => { 386 386 it("should return null for invalid step value (zero)", () => { 387 - expect(parse("*/0 * * * *")).toBeNull(); 387 + expect(() => parse("*/0 * * * *")).toThrow(); 388 388 }); 389 389 390 390 it("should return null for negative step value", () => { 391 - expect(parse("*/-5 * * * *")).toBeNull(); 391 + expect(() => parse("*/-5 * * * *")).toThrow(); 392 392 }); 393 393 394 394 it("should return null for non-numeric step value", () => { 395 - expect(parse("*/abc * * * *")).toBeNull(); 395 + expect(() => parse("*/abc * * * *")).toThrow(); 396 396 }); 397 397 398 398 it("should return null for step with empty string", () => { 399 - expect(parse("/5 * * * *")).toBeNull(); 399 + expect(() => parse("/5 * * * *")).toThrow(); 400 400 }); 401 401 402 402 it("should return null for step at end only", () => { 403 - expect(parse("5/ * * * *")).toBeNull(); 403 + expect(() => parse("5/ * * * *")).toThrow(); 404 404 }); 405 405 406 406 it("should return null for invalid range format in step value", () => { 407 - expect(parse("10-20-30/5 * * * *")).toBeNull(); 407 + expect(() => parse("10-20-30/5 * * * *")).toThrow(); 408 408 }); 409 409 410 410 it("should return null for invalid start value in range with step", () => { 411 - expect(parse("abc-20/5 * * * *")).toBeNull(); 411 + expect(() => parse("abc-20/5 * * * *")).toThrow(); 412 412 }); 413 413 414 414 it("should return null for invalid end value in range with step", () => { 415 - expect(parse("10-xyz/5 * * * *")).toBeNull(); 415 + expect(() => parse("10-xyz/5 * * * *")).toThrow(); 416 416 }); 417 417 }); 418 418 419 419 describe("error cases - ranges", () => { 420 420 it("should return null for invalid range (start > end)", () => { 421 - expect(parse("50-10 * * * *")).toBeNull(); 421 + expect(() => parse("50-10 * * * *")).toThrow(); 422 422 }); 423 423 424 424 it("should return null for out of range value in simple range", () => { 425 - expect(parse("70-80 * * * *")).toBeNull(); 425 + expect(() => parse("70-80 * * * *")).toThrow(); 426 426 }); 427 427 428 428 it("should return null for out of range value in step range", () => { 429 - expect(parse("70-80/5 * * * *")).toBeNull(); 429 + expect(() => parse("70-80/5 * * * *")).toThrow(); 430 430 }); 431 431 432 432 it("should return null for range with only dash", () => { 433 - expect(parse("- * * * *")).toBeNull(); 433 + expect(() => parse("- * * * *")).toThrow(); 434 434 }); 435 435 436 436 it("should return null for multiple dashes in range (malformed input)", () => { 437 437 // Previously this was a parser quirk that silently accepted "1-5-10" as "1-5". 438 438 // Now we return null to catch malformed input early. 439 - expect(parse("1-5-10 * * * *")).toBeNull(); 439 + expect(() => parse("1-5-10 * * * *")).toThrow(); 440 440 }); 441 441 }); 442 442 443 443 describe("error cases - out of range values", () => { 444 444 it("should return null for minute out of range (60)", () => { 445 - expect(parse("60 * * * *")).toBeNull(); 445 + expect(() => parse("60 * * * *")).toThrow(); 446 446 }); 447 447 448 448 it("should return null for minute out of range (negative)", () => { 449 - expect(parse("-1 * * * *")).toBeNull(); 449 + expect(() => parse("-1 * * * *")).toThrow(); 450 450 }); 451 451 452 452 it("should return null for minute out of range (100)", () => { 453 - expect(parse("100 * * * *")).toBeNull(); 453 + expect(() => parse("100 * * * *")).toThrow(); 454 454 }); 455 455 456 456 it("should return null for hour out of range (24)", () => { 457 - expect(parse("* 24 * * *")).toBeNull(); 457 + expect(() => parse("* 24 * * *")).toThrow(); 458 458 }); 459 459 460 460 it("should return null for hour out of range (negative)", () => { 461 - expect(parse("* -1 * * *")).toBeNull(); 461 + expect(() => parse("* -1 * * *")).toThrow(); 462 462 }); 463 463 464 464 it("should return null for day out of range (0)", () => { 465 - expect(parse("* * 0 * *")).toBeNull(); 465 + expect(() => parse("* * 0 * *")).toThrow(); 466 466 }); 467 467 468 468 it("should return null for day out of range (32)", () => { 469 - expect(parse("* * 32 * *")).toBeNull(); 469 + expect(() => parse("* * 32 * *")).toThrow(); 470 470 }); 471 471 472 472 it("should return null for month out of range (0)", () => { 473 473 // Month 0 is invalid because months are 1-12 in cron format 474 - expect(parse("* * * 0 *")).toBeNull(); 474 + expect(() => parse("* * * 0 *")).toThrow(); 475 475 }); 476 476 477 477 it("should return null for month out of range (13)", () => { 478 - expect(parse("* * * 13 *")).toBeNull(); 478 + expect(() => parse("* * * 13 *")).toThrow(); 479 479 }); 480 480 481 481 it("should return null for weekday out of range (8)", () => { 482 - expect(parse("* * * * 8")).toBeNull(); 482 + expect(() => parse("* * * * 8")).toThrow(); 483 483 }); 484 484 }); 485 485 486 486 describe("error cases - invalid value names", () => { 487 487 it("should return null for invalid weekday name", () => { 488 - expect(parse("0 0 * * notaday")).toBeNull(); 488 + expect(() => parse("0 0 * * notaday")).toThrow(); 489 489 }); 490 490 491 491 it("should return null for invalid month name", () => { 492 - expect(parse("0 0 1 notamonth *")).toBeNull(); 492 + expect(() => parse("0 0 1 notamonth *")).toThrow(); 493 493 }); 494 494 495 495 it("should return null for partial month name", () => { 496 - expect(parse("0 0 1 janu *")).toBeNull(); 496 + expect(() => parse("0 0 1 janu *")).toThrow(); 497 497 }); 498 498 499 499 it("should return null for partial weekday name", () => { 500 - expect(parse("0 0 * * mond")).toBeNull(); 500 + expect(() => parse("0 0 * * mond")).toThrow(); 501 501 }); 502 502 503 503 it("should return null for completely invalid field value", () => { 504 - expect(parse("abc * * * *")).toBeNull(); 504 + expect(() => parse("abc * * * *")).toThrow(); 505 505 }); 506 506 507 507 it("should return null for special characters in value", () => { 508 - expect(parse("@ * * * *")).toBeNull(); 508 + expect(() => parse("@ * * * *")).toThrow(); 509 509 }); 510 510 }); 511 511 512 512 describe("error cases - comma-separated issues", () => { 513 513 it("should return null for empty part between commas", () => { 514 - expect(parse("1,,3 * * * *")).toBeNull(); 514 + expect(() => parse("1,,3 * * * *")).toThrow(); 515 515 }); 516 516 517 517 it("should return null for trailing comma", () => { 518 - expect(parse("1,2, * * * *")).toBeNull(); 518 + expect(() => parse("1,2, * * * *")).toThrow(); 519 519 }); 520 520 521 521 it("should return null for leading comma", () => { 522 - expect(parse(",1,2 * * * *")).toBeNull(); 522 + expect(() => parse(",1,2 * * * *")).toThrow(); 523 523 }); 524 524 525 525 it("should return null for invalid value in comma list", () => { 526 - expect(parse("1,2,abc,4 * * * *")).toBeNull(); 526 + expect(() => parse("1,2,abc,4 * * * *")).toThrow(); 527 527 }); 528 528 }); 529 529 530 530 describe("day/month validation", () => { 531 531 it("should return null for impossible day/month combination (Feb 31)", () => { 532 - expect(parse("0 0 31 2 *")).toBeNull(); 532 + expect(() => parse("0 0 31 2 *")).toThrow(); 533 533 }); 534 534 535 535 it("should return null for impossible day/month combination (Feb 30)", () => { 536 - expect(parse("0 0 30 2 *")).toBeNull(); 536 + expect(() => parse("0 0 30 2 *")).toThrow(); 537 537 }); 538 538 539 539 it("should return null for impossible day/month combination (Apr 31)", () => { 540 - expect(parse("0 0 31 4 *")).toBeNull(); 540 + expect(() => parse("0 0 31 4 *")).toThrow(); 541 541 }); 542 542 543 543 it("should return null for impossible day/month combination (Jun 31)", () => { 544 - expect(parse("0 0 31 6 *")).toBeNull(); 544 + expect(() => parse("0 0 31 6 *")).toThrow(); 545 545 }); 546 546 547 547 it("should return null for impossible day/month combination (Sep 31)", () => { 548 - expect(parse("0 0 31 9 *")).toBeNull(); 548 + expect(() => parse("0 0 31 9 *")).toThrow(); 549 549 }); 550 550 551 551 it("should return null for impossible day/month combination (Nov 31)", () => { 552 - expect(parse("0 0 31 11 *")).toBeNull(); 552 + expect(() => parse("0 0 31 11 *")).toThrow(); 553 553 }); 554 554 555 555 it("should allow Feb 29 (exists in leap years)", () => { 556 - expect(parse("0 0 29 2 *")).not.toBeNull(); 556 + expect(() => parse("0 0 29 2 *")).not.toThrow(); 557 557 }); 558 558 559 559 it("should allow day 31 with wildcard month", () => { 560 - expect(parse("0 0 31 * *")).not.toBeNull(); 560 + expect(() => parse("0 0 31 * *")).not.toThrow(); 561 561 }); 562 562 563 563 it("should allow wildcard day with specific month", () => { 564 - expect(parse("0 0 * 2 *")).not.toBeNull(); 564 + expect(() => parse("0 0 * 2 *")).not.toThrow(); 565 565 }); 566 566 567 567 it("should allow day 31 in January", () => { 568 - expect(parse("0 0 31 1 *")).not.toBeNull(); 568 + expect(() => parse("0 0 31 1 *")).not.toThrow(); 569 569 }); 570 570 571 571 it("should allow day 31 in March", () => { 572 - expect(parse("0 0 31 3 *")).not.toBeNull(); 572 + expect(() => parse("0 0 31 3 *")).not.toThrow(); 573 573 }); 574 574 575 575 it("should allow day 31 in May", () => { 576 - expect(parse("0 0 31 5 *")).not.toBeNull(); 576 + expect(() => parse("0 0 31 5 *")).not.toThrow(); 577 577 }); 578 578 579 579 it("should allow day 31 in July", () => { 580 - expect(parse("0 0 31 7 *")).not.toBeNull(); 580 + expect(() => parse("0 0 31 7 *")).not.toThrow(); 581 581 }); 582 582 583 583 it("should allow day 31 in August", () => { 584 - expect(parse("0 0 31 8 *")).not.toBeNull(); 584 + expect(() => parse("0 0 31 8 *")).not.toThrow(); 585 585 }); 586 586 587 587 it("should allow day 31 in October", () => { 588 - expect(parse("0 0 31 10 *")).not.toBeNull(); 588 + expect(() => parse("0 0 31 10 *")).not.toThrow(); 589 589 }); 590 590 591 591 it("should allow day 31 in December", () => { 592 - expect(parse("0 0 31 12 *")).not.toBeNull(); 592 + expect(() => parse("0 0 31 12 *")).not.toThrow(); 593 593 }); 594 594 595 595 it("should allow day 30 in April", () => { 596 - expect(parse("0 0 30 4 *")).not.toBeNull(); 596 + expect(() => parse("0 0 30 4 *")).not.toThrow(); 597 597 }); 598 598 599 599 it("should allow day 30 in June", () => { 600 - expect(parse("0 0 30 6 *")).not.toBeNull(); 600 + expect(() => parse("0 0 30 6 *")).not.toThrow(); 601 601 }); 602 602 603 603 it("should allow day 30 in September", () => { 604 - expect(parse("0 0 30 9 *")).not.toBeNull(); 604 + expect(() => parse("0 0 30 9 *")).not.toThrow(); 605 605 }); 606 606 607 607 it("should allow day 30 in November", () => { 608 - expect(parse("0 0 30 11 *")).not.toBeNull(); 608 + expect(() => parse("0 0 30 11 *")).not.toThrow(); 609 609 }); 610 610 611 611 it("should handle multiple months with mixed valid/invalid days", () => { 612 612 // Days 30-31 in Feb,Apr - Feb 30-31 invalid, but Apr 30 valid 613 - expect(parse("0 0 30-31 2,4 *")).not.toBeNull(); 613 + expect(() => parse("0 0 30-31 2,4 *")).not.toThrow(); 614 614 }); 615 615 616 616 it("should return null when all day/month combinations are invalid", () => { 617 617 // Day 31 only in Feb,Apr,Jun,Sep,Nov (all have < 31 days) 618 - expect(parse("0 0 31 2,4,6,9,11 *")).toBeNull(); 618 + expect(() => parse("0 0 31 2,4,6,9,11 *")).toThrow(); 619 619 }); 620 620 621 621 it("should allow with month name for valid day", () => { 622 - expect(parse("0 0 31 jan *")).not.toBeNull(); 622 + expect(() => parse("0 0 31 jan *")).not.toThrow(); 623 623 }); 624 624 625 625 it("should return null with month name for invalid day", () => { 626 - expect(parse("0 0 31 apr *")).toBeNull(); 626 + expect(() => parse("0 0 31 apr *")).toThrow(); 627 627 }); 628 628 }); 629 629 });
+7 -7
test/timezone.test.ts
··· 469 469 describe("invalid timezone strings", () => { 470 470 it("should return null for completely invalid timezone", () => { 471 471 const date = new Date("2026-03-15T12:00:00Z"); 472 - expect(convertToTimezone(date, "Invalid/Timezone")).toBeNull(); 472 + expect(() => convertToTimezone(date, "Invalid/Timezone")).toThrow(); 473 473 }); 474 474 475 475 it("should return null for misspelled timezone", () => { 476 476 const date = new Date("2026-03-15T12:00:00Z"); 477 - expect(convertToTimezone(date, "America/New_York_City")).toBeNull(); 477 + expect(() => convertToTimezone(date, "America/New_York_City")).toThrow(); 478 478 }); 479 479 480 480 it("should return null for made-up timezone", () => { 481 481 const date = new Date("2026-03-15T12:00:00Z"); 482 - expect(convertToTimezone(date, "Mars/Colony")).toBeNull(); 482 + expect(() => convertToTimezone(date, "Mars/Colony")).toThrow(); 483 483 }); 484 484 485 485 it("should accept lowercase timezone (Intl is case-insensitive)", () => { ··· 489 489 490 490 it("should return null for invalid timezone in convertFromTimezone", () => { 491 491 const date = new Date("2026-03-15T12:00:00Z"); 492 - expect(convertFromTimezone(date, "NotReal/Place")).toBeNull(); 492 + expect(() => convertFromTimezone(date, "NotReal/Place")).toThrow(); 493 493 }); 494 494 }); 495 495 496 496 describe("empty and whitespace timezone", () => { 497 497 it("should return null for empty string timezone", () => { 498 498 const date = new Date("2026-03-15T12:00:00Z"); 499 - expect(convertToTimezone(date, "")).toBeNull(); 499 + expect(() => convertToTimezone(date, "")).toThrow(); 500 500 }); 501 501 502 502 it("should return null for whitespace-only timezone", () => { 503 503 const date = new Date("2026-03-15T12:00:00Z"); 504 - expect(convertToTimezone(date, " ")).toBeNull(); 504 + expect(() => convertToTimezone(date, " ")).toThrow(); 505 505 }); 506 506 507 507 it("should return null for empty string in convertFromTimezone", () => { 508 508 const date = new Date("2026-03-15T12:00:00Z"); 509 - expect(convertFromTimezone(date, "")).toBeNull(); 509 + expect(() => convertFromTimezone(date, "")).toThrow(); 510 510 }); 511 511 }); 512 512
+1 -1
tsconfig.json
··· 15 15 "rootDir": "./src" 16 16 }, 17 17 "include": ["src/**/*"], 18 - "exclude": ["node_modules", "dist", "test", "**/*.test.ts"] 18 + "exclude": ["node_modules", "dist", "test"] 19 19 }
+9
tsconfig.test.json
··· 1 + { 2 + "extends": "./tsconfig.json", 3 + "compilerOptions": { 4 + "rootDir": ".", 5 + "noEmit": true 6 + }, 7 + "include": ["src/**/*", "test/**/*"], 8 + "exclude": ["node_modules", "dist"] 9 + }