From daf60988be2efe4984c4ee54668410bb52ed7c14 Mon Sep 17 00:00:00 2001 From: Marc Weiner Date: Sat, 1 Aug 2026 22:31:26 -0400 Subject: [PATCH 1/4] docs: refresh validation benchmark with fair methodology Re-run against pinned zod 4.4.3, joi 18.2.3, and yup 1.7.1 with fixture checks, abortEarly: false for full invalid scans, and clearer docs. --- .gitignore | 3 +- README.md | 21 ++--- benchmark.js | 213 ++++++++++++++++++++++------------------------ docs/benchmark.md | 87 +++++++++++++++++++ package-lock.json | 18 ++-- package.json | 6 +- 6 files changed, 216 insertions(+), 132 deletions(-) create mode 100644 docs/benchmark.md diff --git a/.gitignore b/.gitignore index 26c6bbc..9bb4548 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ node_modules dist coverage -conf/Conf.d.ts \ No newline at end of file +conf/Conf.d.ts +benchmark-results.json \ No newline at end of file diff --git a/README.md b/README.md index 18dc07c..0fcb878 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,11 @@ [![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) [![AutoRel](https://img.shields.io/badge/%F0%9F%9A%80%20AutoRel-2D4DDE)](https://github.com/mhweiner/autorel) -Lightning-fast, zero-dependency runtime type validation for TS/JS. 25x faster and 15x smaller than zod (4kb vs 60kb gzipped). +Lightning-fast, zero-dependency runtime type validation for TS/JS. ~14Ɨ faster and 15Ɨ smaller than zod (4kb vs 60kb gzipped). **šŸš€ Fast & reliable performance** -- 25x faster than `zod` and `yup`, 4.3x faster than `joi` (see [Performance](#performance) section) +- ~14Ɨ faster than `zod`, ~9Ɨ faster than `joi`, ~160Ɨ faster than `yup` (see [Performance](#performance)) - Supports tree-shaking via ES Modules so you only bundle what you use - No dependencies - 100% test coverage @@ -30,6 +30,7 @@ Lightning-fast, zero-dependency runtime type validation for TS/JS. 25x faster an # Table of contents - [Performance](#performance) +- [Benchmark details](docs/benchmark.md) - [Example](#example) - [Taking advantage of tree-shaking](#taking-advantage-of-tree-shaking) - [Nested objects](#nested-objects) @@ -41,16 +42,16 @@ Lightning-fast, zero-dependency runtime type validation for TS/JS. 25x faster an # Performance -runtyp is designed for speed. Here's how it compares to other popular programmatic validation libraries: +Each library validates the same nested user object **100,000 times with valid input** and **100,000 times with invalid input** (**200,000 runs total**). Lower total time is faster. -| Library | Valid Data | Invalid Data | Total Time | Relative Speed | -|---------|------------|--------------|------------|----------------| -| **runtyp** | 0.0005ms | 0.0009ms | 142.47ms | 1.0x (fastest) | -| **joi** | 0.0042ms | 0.0018ms | 607.70ms | 4.3x slower | -| **yup** | 0.0145ms | 0.0209ms | 3532.78ms | 24.8x slower | -| **zod** | 0.0007ms | 0.0357ms | 3637.74ms | 25.5x slower | +| Library | Total time (200k runs) | vs runtyp | +|---------|------------------------|-----------| +| **runtyp** 1.0.0 | **97 ms** | fastest | +| **joi** 18.2.3 | 901 ms | 9.3Ɨ slower | +| **zod** 4.4.3 | 1,389 ms | 14.3Ɨ slower | +| **yup** 1.7.1 | 15,837 ms | 163Ɨ slower | -*Benchmark results from 100,000 iterations of complex object validation with nested objects, arrays, and various validation rules. Lower times are better.* +Measured 2026-08-02 on Node v24.16.0 with pinned competitor versions. Invalid runs collect all field errors (same depth for every library). **[Full benchmark breakdown →](docs/benchmark.md)** (methodology, per-run timings, test schema, how to reproduce). # Installation diff --git a/benchmark.js b/benchmark.js index 80293e3..ed1b97d 100644 --- a/benchmark.js +++ b/benchmark.js @@ -1,15 +1,18 @@ #!/usr/bin/env node const {performance} = require('perf_hooks'); +const fs = require('fs'); +const path = require('path'); // Import validation libraries const {predicates: p} = require('./dist/index.js'); const zod = require('zod'); const joi = require('joi'); const yup = require('yup'); -// Removed AJV - it's a JSON Schema validator, not a programmatic validation library -// Test data +const ITERATIONS = 100_000; + +// Test data — same payload for every library const validUser = { name: 'John Doe', email: 'john@example.com', @@ -38,7 +41,6 @@ const invalidUser = { tags: [], }; -// Define schemas for each library const runtypSchema = p.object({ name: p.string({len: {min: 1, max: 100}}), email: p.email(), @@ -68,11 +70,9 @@ const zodSchema = zod.object({ }); const joiSchema = joi.object({ - name: joi.string().min(1).max(100) - .required(), + name: joi.string().min(1).max(100).required(), email: joi.string().email().required(), - age: joi.number().min(0).max(150) - .required(), + age: joi.number().min(0).max(150).required(), phone: joi.string().pattern(/^\(\d{3}\) \d{3}-\d{4}$/).required(), address: joi.object({ street: joi.string().min(1).required(), @@ -80,16 +80,13 @@ const joiSchema = joi.object({ state: joi.string().length(2).required(), zip: joi.string().pattern(/^\d{5}$/).required(), }).required(), - tags: joi.array().items(joi.string()).min(1) - .required(), + tags: joi.array().items(joi.string()).min(1).required(), }); const yupSchema = yup.object({ - name: yup.string().min(1).max(100) - .required(), + name: yup.string().min(1).max(100).required(), email: yup.string().email().required(), - age: yup.number().min(0).max(150) - .required(), + age: yup.number().min(0).max(150).required(), phone: yup.string().matches(/^\(\d{3}\) \d{3}-\d{4}$/).required(), address: yup.object({ street: yup.string().min(1).required(), @@ -97,124 +94,122 @@ const yupSchema = yup.object({ state: yup.string().length(2).required(), zip: yup.string().matches(/^\d{5}$/).required(), }).required(), - tags: yup.array().of(yup.string()).min(1) - .required(), + tags: yup.array().of(yup.string()).min(1).required(), }); -// Removed AJV schema definition - -// Benchmark function -function benchmark(name, fn, iterations = 100000) { - - console.log(`\nBenchmarking ${name}...`); - - // Warm up - for (let i = 0; i < 1000; i++) { - - fn(validUser); +const libraries = [ + { + name: 'runtyp', + version: require('./package.json').version, + validate: (data) => runtypSchema(data).isValid, + }, + { + name: 'zod', + version: require('zod/package.json').version, + validate: (data) => zodSchema.safeParse(data).success, + }, + { + name: 'joi', + version: require('joi/package.json').version, + validate: (data) => !joiSchema.validate(data, {abortEarly: false}).error, + }, + { + name: 'yup', + version: require('yup/package.json').version, + validate: (data) => { + try { + yupSchema.validateSync(data, {abortEarly: false}); + return true; + } catch { + return false; + } + }, + }, +]; +function assertFixtures(librariesToCheck) { + for (const {name, validate} of librariesToCheck) { + if (!validate(validUser)) { + throw new Error(`${name}: validUser should pass validation`); + } + if (validate(invalidUser)) { + throw new Error(`${name}: invalidUser should fail validation`); + } } +} - // Benchmark valid data - const validStart = performance.now(); - +function timeRuns(fn, data, iterations) { + const start = performance.now(); for (let i = 0; i < iterations; i++) { - - fn(validUser); - + fn(data); } - const validEnd = performance.now(); - const validTime = validEnd - validStart; - - // Benchmark invalid data - const invalidStart = performance.now(); - - for (let i = 0; i < iterations; i++) { + return performance.now() - start; +} - fn(invalidUser); +function benchmarkLibrary({name, version, validate}, iterations) { + console.log(`\nBenchmarking ${name}@${version}...`); + for (let i = 0; i < 1000; i++) { + validate(validUser); } - const invalidEnd = performance.now(); - const invalidTime = invalidEnd - invalidStart; - - const avgValidTime = validTime / iterations; - const avgInvalidTime = invalidTime / iterations; - console.log(` Valid data: ${avgValidTime.toFixed(4)}ms per validation`); - console.log(` Invalid data: ${avgInvalidTime.toFixed(4)}ms per validation`); - console.log(` Total time: ${(validTime + invalidTime).toFixed(2)}ms`); + const validMs = timeRuns(validate, validUser, iterations); + const invalidMs = timeRuns(validate, invalidUser, iterations); + const totalMs = validMs + invalidMs; - return { + const result = { name, - validTime: avgValidTime, - invalidTime: avgInvalidTime, - totalTime: validTime + invalidTime, + version, + iterations, + validMs, + invalidMs, + totalMs, + avgValidMs: validMs / iterations, + avgInvalidMs: invalidMs / iterations, }; -} - -// Validation functions -const runtypValid = (data) => runtypSchema(data); -const zodValid = (data) => { + console.log(` Valid passes: ${validMs.toFixed(2)}ms total (${result.avgValidMs.toFixed(4)}ms / run)`); + console.log(` Invalid passes: ${invalidMs.toFixed(2)}ms total (${result.avgInvalidMs.toFixed(4)}ms / run)`); + console.log(` Combined: ${totalMs.toFixed(2)}ms total (${iterations.toLocaleString()} valid + ${iterations.toLocaleString()} invalid)`); - try { - - zodSchema.parse(data); - return true; - - } catch { - - return false; - - } - -}; -const joiValid = (data) => { - - const result = joiSchema.validate(data); - - return !result.error; - -}; -const yupValid = (data) => { - - try { - - yupSchema.validateSync(data); - return true; + return result; +} - } catch { +console.log('Validation library benchmark'); +console.log('============================'); +console.log(`Each library validates the same user object ${ITERATIONS.toLocaleString()} times with valid data,`); +console.log(`then ${ITERATIONS.toLocaleString()} times with invalid data (${(ITERATIONS * 2).toLocaleString()} runs total).`); +console.log('Invalid runs collect all field errors (abortEarly: false for Joi/Yup).'); +console.log('Lower total time is faster. See docs/benchmark.md for methodology and per-run breakdown.'); - return false; +assertFixtures(libraries); +console.log('\nFixture check passed: validUser passes and invalidUser fails for every library.'); - } +const results = libraries.map((lib) => benchmarkLibrary(lib, ITERATIONS)); +results.sort((a, b) => a.totalMs - b.totalMs); -}; -// Removed AJV validation function - -// Run benchmarks -console.log('šŸš€ Validation Library Performance Benchmark'); -console.log('=========================================='); -console.log('Testing with 100,000 iterations each...'); - -const results = [ - benchmark('runtyp', runtypValid), - benchmark('zod', zodValid), - benchmark('joi', joiValid), - benchmark('yup', yupValid), -]; +const fastest = results[0].totalMs; -// Sort by total time (fastest first) -results.sort((a, b) => a.totalTime - b.totalTime); - -console.log('\nšŸ“Š Results (fastest to slowest):'); -console.log('================================'); +console.log('\nResults (fastest to slowest, by combined total time):'); +console.log('====================================================='); results.forEach((result, index) => { + const ratio = result.totalMs / fastest; + const suffix = index === 0 ? '(fastest)' : `(${ratio.toFixed(1)}x slower)`; + console.log(`${index + 1}. ${result.name}@${result.version}: ${result.totalMs.toFixed(2)}ms ${suffix}`); +}); - const speedup = results[0].totalTime / result.totalTime; - - console.log(`${index + 1}. ${result.name}: ${result.totalTime.toFixed(2)}ms ${speedup > 1 ? `(${speedup.toFixed(1)}x slower)` : '(fastest)'}`); +const payload = { + runAt: new Date().toISOString(), + nodeVersion: process.version, + iterations: ITERATIONS, + runsPerLibrary: ITERATIONS * 2, + results, +}; -}); +fs.writeFileSync( + path.join(__dirname, 'benchmark-results.json'), + `${JSON.stringify(payload, null, 2)}\n`, +); -console.log('\nāœ… Benchmark complete!'); +console.log('\nWrote benchmark-results.json'); +console.log('Benchmark complete.'); diff --git a/docs/benchmark.md b/docs/benchmark.md new file mode 100644 index 0000000..28dc78b --- /dev/null +++ b/docs/benchmark.md @@ -0,0 +1,87 @@ +# Validation benchmark + +This page explains exactly what the runtyp performance numbers measure and how to reproduce them. + +## What we measure + +Each library validates the **same nested user object** under the same rules: + +- top-level strings (name, email, phone) +- numeric range (age) +- nested address object (street, city, state, zip) +- string array with minimum length (tags) + +For every library we run **two passes**: + +1. **100,000 validations** against valid input (should pass) +2. **100,000 validations** against invalid input (should fail) + +That is **200,000 validation runs per library**. Results are reported as **total wall-clock time** for each pass and combined. Lower is faster. + +Before timing, the script asserts that `validUser` passes and `invalidUser` fails for **every** library. If fixtures drift out of equivalence, the benchmark exits with an error instead of publishing misleading numbers. + +Each library uses its normal non-throwing validation path where one exists (`runtyp` result object, Zod `safeParse`, Joi `validate`, Yup `validateSync` in try/catch). On invalid input, **all libraries collect every field error** — Joi and Yup use `abortEarly: false` so they validate the same depth as runtyp and Zod. + +## Latest results + +Run date: **2026-08-02** Ā· Node **v24.16.0** Ā· [`benchmark.js`](../benchmark.js) + +Competitor versions are **pinned exactly** in `package.json` (`zod` 4.4.3, `joi` 18.2.3, `yup` 1.7.1). + +| Library | Version | Valid (100k runs) | Invalid (100k runs) | **Total (200k runs)** | vs runtyp | +|---------|---------|-------------------|---------------------|----------------------|-----------| +| **runtyp** | 1.0.0 | 36 ms | 61 ms | **97 ms** | fastest | +| **joi** | 18.2.3 | 293 ms | 608 ms | **901 ms** | 9.3Ɨ slower | +| **zod** | 4.4.3 | 56 ms | 1,333 ms | **1,389 ms** | 14.3Ɨ slower | +| **yup** | 1.7.1 | 878 ms | 14,959 ms | **15,837 ms** | 163Ɨ slower | + +### Per-run averages + +| Library | Avg valid pass | Avg invalid pass | +|---------|----------------|------------------| +| **runtyp** | 0.0004 ms | 0.0006 ms | +| **joi** | 0.0029 ms | 0.0061 ms | +| **zod** | 0.0006 ms | 0.0133 ms | +| **yup** | 0.0088 ms | 0.1496 ms | + +Zod **4.4.3** is substantially faster on invalid data than older benchmark runs against Zod 4.1.x (combined total dropped from ~3.6 s to ~1.4 s). We use the latest pinned competitor versions and Zod's `safeParse` API for a fair comparison. + +## Test schema + +All libraries express equivalent constraints. Example (runtyp): + +```typescript +p.object({ + name: p.string({len: {min: 1, max: 100}}), + email: p.email(), + age: p.number({range: {min: 0, max: 150}}), + phone: p.regex(/^\(\d{3}\) \d{3}-\d{4}$/, 'must be valid phone format'), + address: p.object({ + street: p.string({len: {min: 1}}), + city: p.string({len: {min: 1}}), + state: p.string({len: {min: 2, max: 2}}), + zip: p.regex(/^\d{5}$/, 'must be 5 digits'), + }), + tags: p.array(p.string(), {len: {min: 1}}), +}); +``` + +Valid and invalid fixtures live in [`benchmark.js`](../benchmark.js). + +## Reproduce locally + +```bash +git clone https://github.com/logfoxai/runtyp.git +cd runtyp +npm install +npm run benchmark +``` + +The script prints a summary to stdout and writes `benchmark-results.json` (gitignored) with raw timings. + +## Caveats + +- Micro-benchmarks vary by CPU, Node version, and background load. Treat ratios as directional, not guarantees. +- This measures **runtime validation only**, not bundle size. See the README for gzip size comparisons. +- Real apps spend time on I/O, serialization, and business logic; profile your own hot paths before optimizing library choice. +- If your app only needs the first validation error, Joi/Yup with default `abortEarly: true` will be faster on invalid input than these numbers suggest. diff --git a/package-lock.json b/package-lock.json index 44b0b2a..9c16f12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,13 +12,13 @@ "ajv": "^8.18.0", "ajv-formats": "3.0.1", "eslint": "^10.0.0", - "joi": "18.0.1", + "joi": "18.2.3", "kizu": "^4.0.0", "ts-node": "^10.9.0", "typescript": "^5.6.0", "typescript-eslint": "^8.0.0", "yup": "1.7.1", - "zod": "4.1.12" + "zod": "4.4.3" }, "peerDependencies": { "typescript": ">=5.0.0" @@ -1901,9 +1901,9 @@ "license": "ISC" }, "node_modules/joi": { - "version": "18.0.1", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.0.1.tgz", - "integrity": "sha512-IiQpRyypSnLisQf3PwuN2eIHAsAIGZIrLZkd4zdvIar2bDyhM91ubRjy8a3eYablXsh9BeI/c7dmPYHca5qtoA==", + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.3.tgz", + "integrity": "sha512-N5A3KTWQpPWT4ExxxPlUx7WmykGXRzhNidWhV41d6Abu9YfI2NyWCJuxdPnslJCPWtbRpSVOWSnSS6GakLM/Rg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -1913,7 +1913,7 @@ "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", - "@standard-schema/spec": "^1.0.0" + "@standard-schema/spec": "^1.1.0" }, "engines": { "node": ">= 20" @@ -2704,9 +2704,9 @@ } }, "node_modules/zod": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", - "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", "funding": { diff --git a/package.json b/package.json index ebadc84..2bbc872 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "runtyp", "version": "1.0.0", - "description": "Lightning-fast, zero-dependency runtime validation for TS/JS. 25x faster and 15x smaller than zod (4kb vs 60kb gzipped).", + "description": "Lightning-fast, zero-dependency runtime validation for TS/JS. ~14x faster and 15x smaller than zod (4kb vs 60kb gzipped).", "keywords": [ "joi", "zod", @@ -49,12 +49,12 @@ "ajv": "^8.18.0", "ajv-formats": "3.0.1", "eslint": "^10.0.0", - "joi": "18.0.1", + "joi": "18.2.3", "kizu": "^4.0.0", "ts-node": "^10.9.0", "typescript": "^5.6.0", "typescript-eslint": "^8.0.0", "yup": "1.7.1", - "zod": "4.1.12" + "zod": "4.4.3" } } From d7b90a0554592fea7a79f589a9f76eed829d4977 Mon Sep 17 00:00:00 2001 From: Marc Weiner Date: Sat, 1 Aug 2026 22:32:43 -0400 Subject: [PATCH 2/4] docs: list all benchmark library versions explicitly Include runtyp and competitor version numbers in README, benchmark report, and script output. --- README.md | 18 ++++++++++-------- benchmark.js | 6 ++++++ docs/benchmark.md | 27 ++++++++++++++++++--------- 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 0fcb878..e009789 100644 --- a/README.md +++ b/README.md @@ -44,14 +44,16 @@ Lightning-fast, zero-dependency runtime type validation for TS/JS. ~14Ɨ faster Each library validates the same nested user object **100,000 times with valid input** and **100,000 times with invalid input** (**200,000 runs total**). Lower total time is faster. -| Library | Total time (200k runs) | vs runtyp | -|---------|------------------------|-----------| -| **runtyp** 1.0.0 | **97 ms** | fastest | -| **joi** 18.2.3 | 901 ms | 9.3Ɨ slower | -| **zod** 4.4.3 | 1,389 ms | 14.3Ɨ slower | -| **yup** 1.7.1 | 15,837 ms | 163Ɨ slower | - -Measured 2026-08-02 on Node v24.16.0 with pinned competitor versions. Invalid runs collect all field errors (same depth for every library). **[Full benchmark breakdown →](docs/benchmark.md)** (methodology, per-run timings, test schema, how to reproduce). +**Versions tested:** runtyp 1.0.0 Ā· joi 18.2.3 Ā· zod 4.4.3 Ā· yup 1.7.1 Ā· Node v24.16.0 + +| Library | Version | Total time (200k runs) | vs runtyp | +|---------|---------|------------------------|-----------| +| **runtyp** | 1.0.0 | **97 ms** | fastest | +| **joi** | 18.2.3 | 901 ms | 9.3Ɨ slower | +| **zod** | 4.4.3 | 1,389 ms | 14.3Ɨ slower | +| **yup** | 1.7.1 | 15,837 ms | 163Ɨ slower | + +Measured 2026-08-02 with pinned versions above. Invalid runs collect all field errors (same depth for every library). **[Full benchmark breakdown →](docs/benchmark.md)** (methodology, per-run timings, test schema, how to reproduce). # Installation diff --git a/benchmark.js b/benchmark.js index ed1b97d..d050151 100644 --- a/benchmark.js +++ b/benchmark.js @@ -177,6 +177,12 @@ function benchmarkLibrary({name, version, validate}, iterations) { console.log('Validation library benchmark'); console.log('============================'); +console.log('Versions tested:'); +libraries.forEach(({name, version}) => { + console.log(` ${name} ${version}`); +}); +console.log(`Node ${process.version}`); +console.log(''); console.log(`Each library validates the same user object ${ITERATIONS.toLocaleString()} times with valid data,`); console.log(`then ${ITERATIONS.toLocaleString()} times with invalid data (${(ITERATIONS * 2).toLocaleString()} runs total).`); console.log('Invalid runs collect all field errors (abortEarly: false for Joi/Yup).'); diff --git a/docs/benchmark.md b/docs/benchmark.md index 28dc78b..a03554a 100644 --- a/docs/benchmark.md +++ b/docs/benchmark.md @@ -22,11 +22,20 @@ Before timing, the script asserts that `validUser` passes and `invalidUser` fail Each library uses its normal non-throwing validation path where one exists (`runtyp` result object, Zod `safeParse`, Joi `validate`, Yup `validateSync` in try/catch). On invalid input, **all libraries collect every field error** — Joi and Yup use `abortEarly: false` so they validate the same depth as runtyp and Zod. -## Latest results +## Versions tested + +All versions are **pinned exactly** in `package.json` and the lockfile: -Run date: **2026-08-02** Ā· Node **v24.16.0** Ā· [`benchmark.js`](../benchmark.js) +| Library | Version | +|---------|---------| +| **runtyp** | 1.0.0 | +| **joi** | 18.2.3 | +| **zod** | 4.4.3 | +| **yup** | 1.7.1 | -Competitor versions are **pinned exactly** in `package.json` (`zod` 4.4.3, `joi` 18.2.3, `yup` 1.7.1). +Environment: Node **v24.16.0** Ā· run date **2026-08-02** Ā· [`benchmark.js`](../benchmark.js) + +## Latest results | Library | Version | Valid (100k runs) | Invalid (100k runs) | **Total (200k runs)** | vs runtyp | |---------|---------|-------------------|---------------------|----------------------|-----------| @@ -37,12 +46,12 @@ Competitor versions are **pinned exactly** in `package.json` (`zod` 4.4.3, `joi` ### Per-run averages -| Library | Avg valid pass | Avg invalid pass | -|---------|----------------|------------------| -| **runtyp** | 0.0004 ms | 0.0006 ms | -| **joi** | 0.0029 ms | 0.0061 ms | -| **zod** | 0.0006 ms | 0.0133 ms | -| **yup** | 0.0088 ms | 0.1496 ms | +| Library | Version | Avg valid pass | Avg invalid pass | +|---------|---------|----------------|------------------| +| **runtyp** | 1.0.0 | 0.0004 ms | 0.0006 ms | +| **joi** | 18.2.3 | 0.0029 ms | 0.0061 ms | +| **zod** | 4.4.3 | 0.0006 ms | 0.0133 ms | +| **yup** | 1.7.1 | 0.0088 ms | 0.1496 ms | Zod **4.4.3** is substantially faster on invalid data than older benchmark runs against Zod 4.1.x (combined total dropped from ~3.6 s to ~1.4 s). We use the latest pinned competitor versions and Zod's `safeParse` API for a fair comparison. From a78843be8a8e94f310556471cfb460cf962d383f Mon Sep 17 00:00:00 2001 From: Marc Weiner Date: Sat, 1 Aug 2026 22:52:32 -0400 Subject: [PATCH 3/4] docs: use realistic API payload in validation benchmark Replace the simple user fixture with an event-ingestion payload (nested objects, enums, UUIDs, URL arrays, string map) and remove stale conf/Conf.d.ts gitignore entry. --- .gitignore | 1 - README.md | 14 +-- benchmark.js | 284 ++++++++++++++++++++++++++++++++++------------ docs/benchmark.md | 69 +++++++---- package.json | 2 +- 5 files changed, 264 insertions(+), 106 deletions(-) diff --git a/.gitignore b/.gitignore index 9bb4548..24283bb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,4 @@ node_modules dist coverage -conf/Conf.d.ts benchmark-results.json \ No newline at end of file diff --git a/README.md b/README.md index e009789..0fd0f4e 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,11 @@ [![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) [![AutoRel](https://img.shields.io/badge/%F0%9F%9A%80%20AutoRel-2D4DDE)](https://github.com/mhweiner/autorel) -Lightning-fast, zero-dependency runtime type validation for TS/JS. ~14Ɨ faster and 15Ɨ smaller than zod (4kb vs 60kb gzipped). +Lightning-fast, zero-dependency runtime type validation for TS/JS. ~9Ɨ faster and 15Ɨ smaller than zod (4kb vs 60kb gzipped). **šŸš€ Fast & reliable performance** -- ~14Ɨ faster than `zod`, ~9Ɨ faster than `joi`, ~160Ɨ faster than `yup` (see [Performance](#performance)) +- ~9Ɨ faster than `zod`, ~6Ɨ faster than `joi`, ~87Ɨ faster than `yup` (see [Performance](#performance)) - Supports tree-shaking via ES Modules so you only bundle what you use - No dependencies - 100% test coverage @@ -42,16 +42,16 @@ Lightning-fast, zero-dependency runtime type validation for TS/JS. ~14Ɨ faster # Performance -Each library validates the same nested user object **100,000 times with valid input** and **100,000 times with invalid input** (**200,000 runs total**). Lower total time is faster. +Each library validates the same **API event-ingestion payload** (nested objects, enums, UUIDs, URL arrays, string map) **100,000 times with valid input** and **100,000 times with invalid input** (**200,000 runs total**). Lower total time is faster. **Versions tested:** runtyp 1.0.0 Ā· joi 18.2.3 Ā· zod 4.4.3 Ā· yup 1.7.1 Ā· Node v24.16.0 | Library | Version | Total time (200k runs) | vs runtyp | |---------|---------|------------------------|-----------| -| **runtyp** | 1.0.0 | **97 ms** | fastest | -| **joi** | 18.2.3 | 901 ms | 9.3Ɨ slower | -| **zod** | 4.4.3 | 1,389 ms | 14.3Ɨ slower | -| **yup** | 1.7.1 | 15,837 ms | 163Ɨ slower | +| **runtyp** | 1.0.0 | **410 ms** | fastest | +| **joi** | 18.2.3 | 2,634 ms | 6.4Ɨ slower | +| **zod** | 4.4.3 | 3,815 ms | 9.3Ɨ slower | +| **yup** | 1.7.1 | 35,781 ms | 87Ɨ slower | Measured 2026-08-02 with pinned versions above. Invalid runs collect all field errors (same depth for every library). **[Full benchmark breakdown →](docs/benchmark.md)** (methodology, per-run timings, test schema, how to reproduce). diff --git a/benchmark.js b/benchmark.js index d050151..20e2024 100644 --- a/benchmark.js +++ b/benchmark.js @@ -4,7 +4,6 @@ const {performance} = require('perf_hooks'); const fs = require('fs'); const path = require('path'); -// Import validation libraries const {predicates: p} = require('./dist/index.js'); const zod = require('zod'); const joi = require('joi'); @@ -12,89 +11,228 @@ const yup = require('yup'); const ITERATIONS = 100_000; -// Test data — same payload for every library -const validUser = { - name: 'John Doe', - email: 'john@example.com', - age: 30, - phone: '(555) 123-4567', - address: { - street: '123 Main St', - city: 'Anytown', - state: 'CA', - zip: '12345', +const Severity = Object.freeze({ + DEBUG: 'debug', + INFO: 'info', + WARN: 'warn', + ERROR: 'error', +}); + +const Role = Object.freeze({ + ADMIN: 'admin', + MEMBER: 'member', + VIEWER: 'viewer', +}); + +const ISO_DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/; +const SEMVER = /^\d+\.\d+\.\d+$/; + +// Realistic API event-ingestion payload (nested objects, enums, UUIDs, URL array items, string map) +const validPayload = { + id: '550e8400-e29b-41d4-a716-446655440000', + appId: '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + occurredAt: '2026-08-01T12:00:00.000Z', + severity: 'error', + message: 'Connection timeout while fetching user profile', + fingerprint: 'pg-timeout-users-v2', + source: { + service: 'api-service', + environment: 'production', + host: 'api-1.prod.example.com', + release: '2.5.1', + region: 'us-east-1', + }, + actor: { + id: '7c9e6679-7425-40de-944b-e07fc1f90ae7', + email: 'admin@example.com', + role: 'admin', + }, + tags: ['timeout', 'postgres', 'critical'], + occurrences: [ + { + at: '2026-08-01T12:00:00.000Z', + count: 1, + requestUrl: 'https://api.example.com/v1/users/profile', + }, + { + at: '2026-08-01T12:01:15.000Z', + count: 4, + requestUrl: 'https://api.example.com/v1/teams/members', + }, + ], + attributes: { + dbHost: 'postgres.internal', + queryMs: '842', + pool: 'primary', }, - tags: ['developer', 'typescript', 'nodejs'], }; -const invalidUser = { - name: '', - email: 'invalid-email', - age: -5, - phone: 'not-a-phone', - address: { - street: '', - city: '', - state: 'INVALID', - zip: 'not-a-zip', +const invalidPayload = { + id: 'not-a-uuid', + appId: 'also-bad', + occurredAt: 'yesterday', + severity: 'critical', + message: '', + fingerprint: '', + source: { + service: '', + environment: 'production', + host: '', + release: 'v2.5', + region: '', + }, + actor: { + id: 'bad', + email: 'not-email', + role: 'superuser', + assigneeId: 'bad-uuid', }, tags: [], + occurrences: [ + { + at: 'invalid', + count: 0, + requestUrl: 'ftp://bad.example.com/evil', + }, + { + at: '2026-08-01T12:00:00.000Z', + count: -1, + requestUrl: 'not-a-url', + }, + ], + attributes: { + dbHost: 123, + queryMs: null, + }, }; +const occurrenceRuntyp = p.object({ + at: p.regex(ISO_DATETIME, 'must be ISO-8601 UTC datetime'), + count: p.number({range: {min: 1, max: 1_000_000}}), + requestUrl: p.url(), +}); + const runtypSchema = p.object({ - name: p.string({len: {min: 1, max: 100}}), - email: p.email(), - age: p.number({range: {min: 0, max: 150}}), - phone: p.regex(/^\(\d{3}\) \d{3}-\d{4}$/, 'must be valid phone format'), - address: p.object({ - street: p.string({len: {min: 1}}), - city: p.string({len: {min: 1}}), - state: p.string({len: {min: 2, max: 2}}), - zip: p.regex(/^\d{5}$/, 'must be 5 digits'), + id: p.uuid(), + appId: p.uuid(), + occurredAt: p.regex(ISO_DATETIME, 'must be ISO-8601 UTC datetime'), + severity: p.enumValue(Severity), + message: p.string({len: {min: 1, max: 10_000}}), + fingerprint: p.string({len: {min: 1, max: 256}}), + source: p.object({ + service: p.string({len: {min: 1, max: 128}}), + environment: p.string({len: {min: 1, max: 64}}), + host: p.string({len: {min: 1, max: 253}}), + release: p.regex(SEMVER, 'must be semver'), + region: p.string({len: {min: 1, max: 32}}), + }), + actor: p.object({ + id: p.uuid(), + email: p.email(), + role: p.enumValue(Role), + assigneeId: p.optional(p.uuid()), }), - tags: p.array(p.string(), {len: {min: 1}}), + tags: p.array(p.string({len: {min: 1, max: 64}}), {len: {min: 1, max: 50}}), + occurrences: p.array(occurrenceRuntyp, {len: {min: 1, max: 100}}), + attributes: p.record(p.string({len: {min: 1, max: 512}})), +}); + +const occurrenceZod = zod.object({ + at: zod.string().regex(ISO_DATETIME), + count: zod.number().int().min(1).max(1_000_000), + requestUrl: zod.string().url(), }); const zodSchema = zod.object({ - name: zod.string().min(1).max(100), - email: zod.string().email(), - age: zod.number().min(0).max(150), - phone: zod.string().regex(/^\(\d{3}\) \d{3}-\d{4}$/), - address: zod.object({ - street: zod.string().min(1), - city: zod.string().min(1), - state: zod.string().length(2), - zip: zod.string().regex(/^\d{5}$/), + id: zod.string().uuid(), + appId: zod.string().uuid(), + occurredAt: zod.string().regex(ISO_DATETIME), + severity: zod.enum(['debug', 'info', 'warn', 'error']), + message: zod.string().min(1).max(10_000), + fingerprint: zod.string().min(1).max(256), + source: zod.object({ + service: zod.string().min(1).max(128), + environment: zod.string().min(1).max(64), + host: zod.string().min(1).max(253), + release: zod.string().regex(SEMVER), + region: zod.string().min(1).max(32), }), - tags: zod.array(zod.string()).min(1), + actor: zod.object({ + id: zod.string().uuid(), + email: zod.string().email(), + role: zod.enum(['admin', 'member', 'viewer']), + assigneeId: zod.string().uuid().optional(), + }), + tags: zod.array(zod.string().min(1).max(64)).min(1).max(50), + occurrences: zod.array(occurrenceZod).min(1).max(100), + attributes: zod.record(zod.string(), zod.string().min(1).max(512)), +}); + +const occurrenceJoi = joi.object({ + at: joi.string().pattern(ISO_DATETIME).required(), + count: joi.number().integer().min(1).max(1_000_000).required(), + requestUrl: joi.string().uri().required(), }); const joiSchema = joi.object({ - name: joi.string().min(1).max(100).required(), - email: joi.string().email().required(), - age: joi.number().min(0).max(150).required(), - phone: joi.string().pattern(/^\(\d{3}\) \d{3}-\d{4}$/).required(), - address: joi.object({ - street: joi.string().min(1).required(), - city: joi.string().min(1).required(), - state: joi.string().length(2).required(), - zip: joi.string().pattern(/^\d{5}$/).required(), + id: joi.string().uuid().required(), + appId: joi.string().uuid().required(), + occurredAt: joi.string().pattern(ISO_DATETIME).required(), + severity: joi.string().valid('debug', 'info', 'warn', 'error').required(), + message: joi.string().min(1).max(10_000).required(), + fingerprint: joi.string().min(1).max(256).required(), + source: joi.object({ + service: joi.string().min(1).max(128).required(), + environment: joi.string().min(1).max(64).required(), + host: joi.string().min(1).max(253).required(), + release: joi.string().pattern(SEMVER).required(), + region: joi.string().min(1).max(32).required(), }).required(), - tags: joi.array().items(joi.string()).min(1).required(), + actor: joi.object({ + id: joi.string().uuid().required(), + email: joi.string().email().required(), + role: joi.string().valid('admin', 'member', 'viewer').required(), + assigneeId: joi.string().uuid(), + }).required(), + tags: joi.array().items(joi.string().min(1).max(64)).min(1).max(50).required(), + occurrences: joi.array().items(occurrenceJoi).min(1).max(100).required(), + attributes: joi.object().pattern(joi.string(), joi.string().min(1).max(512)).required(), +}); + +const occurrenceYup = yup.object({ + at: yup.string().matches(ISO_DATETIME).required(), + count: yup.number().integer().min(1).max(1_000_000).required(), + requestUrl: yup.string().url().required(), }); const yupSchema = yup.object({ - name: yup.string().min(1).max(100).required(), - email: yup.string().email().required(), - age: yup.number().min(0).max(150).required(), - phone: yup.string().matches(/^\(\d{3}\) \d{3}-\d{4}$/).required(), - address: yup.object({ - street: yup.string().min(1).required(), - city: yup.string().min(1).required(), - state: yup.string().length(2).required(), - zip: yup.string().matches(/^\d{5}$/).required(), + id: yup.string().uuid().required(), + appId: yup.string().uuid().required(), + occurredAt: yup.string().matches(ISO_DATETIME).required(), + severity: yup.string().oneOf(['debug', 'info', 'warn', 'error']).required(), + message: yup.string().min(1).max(10_000).required(), + fingerprint: yup.string().min(1).max(256).required(), + source: yup.object({ + service: yup.string().min(1).max(128).required(), + environment: yup.string().min(1).max(64).required(), + host: yup.string().min(1).max(253).required(), + release: yup.string().matches(SEMVER).required(), + region: yup.string().min(1).max(32).required(), + }).required(), + actor: yup.object({ + id: yup.string().uuid().required(), + email: yup.string().email().required(), + role: yup.string().oneOf(['admin', 'member', 'viewer']).required(), + assigneeId: yup.string().uuid().optional(), + }).required(), + tags: yup.array().of(yup.string().min(1).max(64)).min(1).max(50).required(), + occurrences: yup.array().of(occurrenceYup).min(1).max(100).required(), + attributes: yup.object().test('string-record', 'attributes must be a string map', (value) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + return Object.values(value).every((entry) => typeof entry === 'string' && entry.length >= 1 && entry.length <= 512); }).required(), - tags: yup.array().of(yup.string()).min(1).required(), }); const libraries = [ @@ -129,11 +267,11 @@ const libraries = [ function assertFixtures(librariesToCheck) { for (const {name, validate} of librariesToCheck) { - if (!validate(validUser)) { - throw new Error(`${name}: validUser should pass validation`); + if (!validate(validPayload)) { + throw new Error(`${name}: validPayload should pass validation`); } - if (validate(invalidUser)) { - throw new Error(`${name}: invalidUser should fail validation`); + if (validate(invalidPayload)) { + throw new Error(`${name}: invalidPayload should fail validation`); } } } @@ -150,11 +288,11 @@ function benchmarkLibrary({name, version, validate}, iterations) { console.log(`\nBenchmarking ${name}@${version}...`); for (let i = 0; i < 1000; i++) { - validate(validUser); + validate(validPayload); } - const validMs = timeRuns(validate, validUser, iterations); - const invalidMs = timeRuns(validate, invalidUser, iterations); + const validMs = timeRuns(validate, validPayload, iterations); + const invalidMs = timeRuns(validate, invalidPayload, iterations); const totalMs = validMs + invalidMs; const result = { @@ -177,19 +315,20 @@ function benchmarkLibrary({name, version, validate}, iterations) { console.log('Validation library benchmark'); console.log('============================'); +console.log('Scenario: API event-ingestion payload (nested objects, enums, UUIDs, URL arrays, string map)'); console.log('Versions tested:'); libraries.forEach(({name, version}) => { console.log(` ${name} ${version}`); }); console.log(`Node ${process.version}`); console.log(''); -console.log(`Each library validates the same user object ${ITERATIONS.toLocaleString()} times with valid data,`); +console.log(`Each library validates the same payload ${ITERATIONS.toLocaleString()} times with valid data,`); console.log(`then ${ITERATIONS.toLocaleString()} times with invalid data (${(ITERATIONS * 2).toLocaleString()} runs total).`); console.log('Invalid runs collect all field errors (abortEarly: false for Joi/Yup).'); console.log('Lower total time is faster. See docs/benchmark.md for methodology and per-run breakdown.'); assertFixtures(libraries); -console.log('\nFixture check passed: validUser passes and invalidUser fails for every library.'); +console.log('\nFixture check passed: validPayload passes and invalidPayload fails for every library.'); const results = libraries.map((lib) => benchmarkLibrary(lib, ITERATIONS)); results.sort((a, b) => a.totalMs - b.totalMs); @@ -207,6 +346,7 @@ results.forEach((result, index) => { const payload = { runAt: new Date().toISOString(), nodeVersion: process.version, + scenario: 'api-event-ingestion', iterations: ITERATIONS, runsPerLibrary: ITERATIONS * 2, results, diff --git a/docs/benchmark.md b/docs/benchmark.md index a03554a..7e508ba 100644 --- a/docs/benchmark.md +++ b/docs/benchmark.md @@ -4,12 +4,14 @@ This page explains exactly what the runtyp performance numbers measure and how t ## What we measure -Each library validates the **same nested user object** under the same rules: +Each library validates the **same API event-ingestion payload** — the kind of structured JSON an observability API might accept on ingest: -- top-level strings (name, email, phone) -- numeric range (age) -- nested address object (street, city, state, zip) -- string array with minimum length (tags) +- top-level UUIDs, ISO timestamps, enums, and bounded strings +- nested `source` and `actor` objects (service metadata + user context) +- optional fields (`assigneeId`) +- `tags` string array with length bounds +- `occurrences` array of objects (timestamp, count, request URL) +- `attributes` open string map (`Record`) For every library we run **two passes**: @@ -18,7 +20,7 @@ For every library we run **two passes**: That is **200,000 validation runs per library**. Results are reported as **total wall-clock time** for each pass and combined. Lower is faster. -Before timing, the script asserts that `validUser` passes and `invalidUser` fails for **every** library. If fixtures drift out of equivalence, the benchmark exits with an error instead of publishing misleading numbers. +Before timing, the script asserts that `validPayload` passes and `invalidPayload` fails for **every** library. If fixtures drift out of equivalence, the benchmark exits with an error instead of publishing misleading numbers. Each library uses its normal non-throwing validation path where one exists (`runtyp` result object, Zod `safeParse`, Joi `validate`, Yup `validateSync` in try/catch). On invalid input, **all libraries collect every field error** — Joi and Yup use `abortEarly: false` so they validate the same depth as runtyp and Zod. @@ -39,39 +41,56 @@ Environment: Node **v24.16.0** Ā· run date **2026-08-02** Ā· [`benchmark.js`](.. | Library | Version | Valid (100k runs) | Invalid (100k runs) | **Total (200k runs)** | vs runtyp | |---------|---------|-------------------|---------------------|----------------------|-----------| -| **runtyp** | 1.0.0 | 36 ms | 61 ms | **97 ms** | fastest | -| **joi** | 18.2.3 | 293 ms | 608 ms | **901 ms** | 9.3Ɨ slower | -| **zod** | 4.4.3 | 56 ms | 1,333 ms | **1,389 ms** | 14.3Ɨ slower | -| **yup** | 1.7.1 | 878 ms | 14,959 ms | **15,837 ms** | 163Ɨ slower | +| **runtyp** | 1.0.0 | 115 ms | 295 ms | **410 ms** | fastest | +| **joi** | 18.2.3 | 846 ms | 1,788 ms | **2,634 ms** | 6.4Ɨ slower | +| **zod** | 4.4.3 | 261 ms | 3,554 ms | **3,815 ms** | 9.3Ɨ slower | +| **yup** | 1.7.1 | 2,413 ms | 33,368 ms | **35,781 ms** | 87Ɨ slower | ### Per-run averages | Library | Version | Avg valid pass | Avg invalid pass | |---------|---------|----------------|------------------| -| **runtyp** | 1.0.0 | 0.0004 ms | 0.0006 ms | -| **joi** | 18.2.3 | 0.0029 ms | 0.0061 ms | -| **zod** | 4.4.3 | 0.0006 ms | 0.0133 ms | -| **yup** | 1.7.1 | 0.0088 ms | 0.1496 ms | +| **runtyp** | 1.0.0 | 0.0012 ms | 0.0029 ms | +| **joi** | 18.2.3 | 0.0085 ms | 0.0179 ms | +| **zod** | 4.4.3 | 0.0026 ms | 0.0355 ms | +| **yup** | 1.7.1 | 0.0241 ms | 0.3337 ms | -Zod **4.4.3** is substantially faster on invalid data than older benchmark runs against Zod 4.1.x (combined total dropped from ~3.6 s to ~1.4 s). We use the latest pinned competitor versions and Zod's `safeParse` API for a fair comparison. +We use the latest pinned competitor versions and Zod's `safeParse` API for a fair comparison. ## Test schema All libraries express equivalent constraints. Example (runtyp): ```typescript +const occurrence = p.object({ + at: p.regex(ISO_DATETIME, 'must be ISO-8601 UTC datetime'), + count: p.number({range: {min: 1, max: 1_000_000}}), + requestUrl: p.url(), +}); + p.object({ - name: p.string({len: {min: 1, max: 100}}), - email: p.email(), - age: p.number({range: {min: 0, max: 150}}), - phone: p.regex(/^\(\d{3}\) \d{3}-\d{4}$/, 'must be valid phone format'), - address: p.object({ - street: p.string({len: {min: 1}}), - city: p.string({len: {min: 1}}), - state: p.string({len: {min: 2, max: 2}}), - zip: p.regex(/^\d{5}$/, 'must be 5 digits'), + id: p.uuid(), + appId: p.uuid(), + occurredAt: p.regex(ISO_DATETIME, 'must be ISO-8601 UTC datetime'), + severity: p.enumValue(Severity), + message: p.string({len: {min: 1, max: 10_000}}), + fingerprint: p.string({len: {min: 1, max: 256}}), + source: p.object({ + service: p.string({len: {min: 1, max: 128}}), + environment: p.string({len: {min: 1, max: 64}}), + host: p.string({len: {min: 1, max: 253}}), + release: p.regex(SEMVER, 'must be semver'), + region: p.string({len: {min: 1, max: 32}}), + }), + actor: p.object({ + id: p.uuid(), + email: p.email(), + role: p.enumValue(Role), + assigneeId: p.optional(p.uuid()), }), - tags: p.array(p.string(), {len: {min: 1}}), + tags: p.array(p.string({len: {min: 1, max: 64}}), {len: {min: 1, max: 50}}), + occurrences: p.array(occurrence, {len: {min: 1, max: 100}}), + attributes: p.record(p.string({len: {min: 1, max: 512}})), }); ``` diff --git a/package.json b/package.json index 2bbc872..4341a9f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "runtyp", "version": "1.0.0", - "description": "Lightning-fast, zero-dependency runtime validation for TS/JS. ~14x faster and 15x smaller than zod (4kb vs 60kb gzipped).", + "description": "Lightning-fast, zero-dependency runtime validation for TS/JS. ~9x faster and 15x smaller than zod (4kb vs 60kb gzipped).", "keywords": [ "joi", "zod", From 0e82df5f9af69e8662064f00ccbd502084f87e11 Mon Sep 17 00:00:00 2001 From: Marc Weiner Date: Sat, 1 Aug 2026 22:58:19 -0400 Subject: [PATCH 4/4] Revert "docs: use realistic API payload in validation benchmark" This reverts commit a78843be8a8e94f310556471cfb460cf962d383f. --- .gitignore | 1 + README.md | 14 +-- benchmark.js | 284 ++++++++++++---------------------------------- docs/benchmark.md | 69 ++++------- package.json | 2 +- 5 files changed, 106 insertions(+), 264 deletions(-) diff --git a/.gitignore b/.gitignore index 24283bb..9bb4548 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ node_modules dist coverage +conf/Conf.d.ts benchmark-results.json \ No newline at end of file diff --git a/README.md b/README.md index 0fd0f4e..e009789 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,11 @@ [![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) [![AutoRel](https://img.shields.io/badge/%F0%9F%9A%80%20AutoRel-2D4DDE)](https://github.com/mhweiner/autorel) -Lightning-fast, zero-dependency runtime type validation for TS/JS. ~9Ɨ faster and 15Ɨ smaller than zod (4kb vs 60kb gzipped). +Lightning-fast, zero-dependency runtime type validation for TS/JS. ~14Ɨ faster and 15Ɨ smaller than zod (4kb vs 60kb gzipped). **šŸš€ Fast & reliable performance** -- ~9Ɨ faster than `zod`, ~6Ɨ faster than `joi`, ~87Ɨ faster than `yup` (see [Performance](#performance)) +- ~14Ɨ faster than `zod`, ~9Ɨ faster than `joi`, ~160Ɨ faster than `yup` (see [Performance](#performance)) - Supports tree-shaking via ES Modules so you only bundle what you use - No dependencies - 100% test coverage @@ -42,16 +42,16 @@ Lightning-fast, zero-dependency runtime type validation for TS/JS. ~9Ɨ faster a # Performance -Each library validates the same **API event-ingestion payload** (nested objects, enums, UUIDs, URL arrays, string map) **100,000 times with valid input** and **100,000 times with invalid input** (**200,000 runs total**). Lower total time is faster. +Each library validates the same nested user object **100,000 times with valid input** and **100,000 times with invalid input** (**200,000 runs total**). Lower total time is faster. **Versions tested:** runtyp 1.0.0 Ā· joi 18.2.3 Ā· zod 4.4.3 Ā· yup 1.7.1 Ā· Node v24.16.0 | Library | Version | Total time (200k runs) | vs runtyp | |---------|---------|------------------------|-----------| -| **runtyp** | 1.0.0 | **410 ms** | fastest | -| **joi** | 18.2.3 | 2,634 ms | 6.4Ɨ slower | -| **zod** | 4.4.3 | 3,815 ms | 9.3Ɨ slower | -| **yup** | 1.7.1 | 35,781 ms | 87Ɨ slower | +| **runtyp** | 1.0.0 | **97 ms** | fastest | +| **joi** | 18.2.3 | 901 ms | 9.3Ɨ slower | +| **zod** | 4.4.3 | 1,389 ms | 14.3Ɨ slower | +| **yup** | 1.7.1 | 15,837 ms | 163Ɨ slower | Measured 2026-08-02 with pinned versions above. Invalid runs collect all field errors (same depth for every library). **[Full benchmark breakdown →](docs/benchmark.md)** (methodology, per-run timings, test schema, how to reproduce). diff --git a/benchmark.js b/benchmark.js index 20e2024..d050151 100644 --- a/benchmark.js +++ b/benchmark.js @@ -4,6 +4,7 @@ const {performance} = require('perf_hooks'); const fs = require('fs'); const path = require('path'); +// Import validation libraries const {predicates: p} = require('./dist/index.js'); const zod = require('zod'); const joi = require('joi'); @@ -11,228 +12,89 @@ const yup = require('yup'); const ITERATIONS = 100_000; -const Severity = Object.freeze({ - DEBUG: 'debug', - INFO: 'info', - WARN: 'warn', - ERROR: 'error', -}); - -const Role = Object.freeze({ - ADMIN: 'admin', - MEMBER: 'member', - VIEWER: 'viewer', -}); - -const ISO_DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/; -const SEMVER = /^\d+\.\d+\.\d+$/; - -// Realistic API event-ingestion payload (nested objects, enums, UUIDs, URL array items, string map) -const validPayload = { - id: '550e8400-e29b-41d4-a716-446655440000', - appId: '6ba7b810-9dad-11d1-80b4-00c04fd430c8', - occurredAt: '2026-08-01T12:00:00.000Z', - severity: 'error', - message: 'Connection timeout while fetching user profile', - fingerprint: 'pg-timeout-users-v2', - source: { - service: 'api-service', - environment: 'production', - host: 'api-1.prod.example.com', - release: '2.5.1', - region: 'us-east-1', - }, - actor: { - id: '7c9e6679-7425-40de-944b-e07fc1f90ae7', - email: 'admin@example.com', - role: 'admin', - }, - tags: ['timeout', 'postgres', 'critical'], - occurrences: [ - { - at: '2026-08-01T12:00:00.000Z', - count: 1, - requestUrl: 'https://api.example.com/v1/users/profile', - }, - { - at: '2026-08-01T12:01:15.000Z', - count: 4, - requestUrl: 'https://api.example.com/v1/teams/members', - }, - ], - attributes: { - dbHost: 'postgres.internal', - queryMs: '842', - pool: 'primary', +// Test data — same payload for every library +const validUser = { + name: 'John Doe', + email: 'john@example.com', + age: 30, + phone: '(555) 123-4567', + address: { + street: '123 Main St', + city: 'Anytown', + state: 'CA', + zip: '12345', }, + tags: ['developer', 'typescript', 'nodejs'], }; -const invalidPayload = { - id: 'not-a-uuid', - appId: 'also-bad', - occurredAt: 'yesterday', - severity: 'critical', - message: '', - fingerprint: '', - source: { - service: '', - environment: 'production', - host: '', - release: 'v2.5', - region: '', - }, - actor: { - id: 'bad', - email: 'not-email', - role: 'superuser', - assigneeId: 'bad-uuid', +const invalidUser = { + name: '', + email: 'invalid-email', + age: -5, + phone: 'not-a-phone', + address: { + street: '', + city: '', + state: 'INVALID', + zip: 'not-a-zip', }, tags: [], - occurrences: [ - { - at: 'invalid', - count: 0, - requestUrl: 'ftp://bad.example.com/evil', - }, - { - at: '2026-08-01T12:00:00.000Z', - count: -1, - requestUrl: 'not-a-url', - }, - ], - attributes: { - dbHost: 123, - queryMs: null, - }, }; -const occurrenceRuntyp = p.object({ - at: p.regex(ISO_DATETIME, 'must be ISO-8601 UTC datetime'), - count: p.number({range: {min: 1, max: 1_000_000}}), - requestUrl: p.url(), -}); - const runtypSchema = p.object({ - id: p.uuid(), - appId: p.uuid(), - occurredAt: p.regex(ISO_DATETIME, 'must be ISO-8601 UTC datetime'), - severity: p.enumValue(Severity), - message: p.string({len: {min: 1, max: 10_000}}), - fingerprint: p.string({len: {min: 1, max: 256}}), - source: p.object({ - service: p.string({len: {min: 1, max: 128}}), - environment: p.string({len: {min: 1, max: 64}}), - host: p.string({len: {min: 1, max: 253}}), - release: p.regex(SEMVER, 'must be semver'), - region: p.string({len: {min: 1, max: 32}}), - }), - actor: p.object({ - id: p.uuid(), - email: p.email(), - role: p.enumValue(Role), - assigneeId: p.optional(p.uuid()), + name: p.string({len: {min: 1, max: 100}}), + email: p.email(), + age: p.number({range: {min: 0, max: 150}}), + phone: p.regex(/^\(\d{3}\) \d{3}-\d{4}$/, 'must be valid phone format'), + address: p.object({ + street: p.string({len: {min: 1}}), + city: p.string({len: {min: 1}}), + state: p.string({len: {min: 2, max: 2}}), + zip: p.regex(/^\d{5}$/, 'must be 5 digits'), }), - tags: p.array(p.string({len: {min: 1, max: 64}}), {len: {min: 1, max: 50}}), - occurrences: p.array(occurrenceRuntyp, {len: {min: 1, max: 100}}), - attributes: p.record(p.string({len: {min: 1, max: 512}})), -}); - -const occurrenceZod = zod.object({ - at: zod.string().regex(ISO_DATETIME), - count: zod.number().int().min(1).max(1_000_000), - requestUrl: zod.string().url(), + tags: p.array(p.string(), {len: {min: 1}}), }); const zodSchema = zod.object({ - id: zod.string().uuid(), - appId: zod.string().uuid(), - occurredAt: zod.string().regex(ISO_DATETIME), - severity: zod.enum(['debug', 'info', 'warn', 'error']), - message: zod.string().min(1).max(10_000), - fingerprint: zod.string().min(1).max(256), - source: zod.object({ - service: zod.string().min(1).max(128), - environment: zod.string().min(1).max(64), - host: zod.string().min(1).max(253), - release: zod.string().regex(SEMVER), - region: zod.string().min(1).max(32), + name: zod.string().min(1).max(100), + email: zod.string().email(), + age: zod.number().min(0).max(150), + phone: zod.string().regex(/^\(\d{3}\) \d{3}-\d{4}$/), + address: zod.object({ + street: zod.string().min(1), + city: zod.string().min(1), + state: zod.string().length(2), + zip: zod.string().regex(/^\d{5}$/), }), - actor: zod.object({ - id: zod.string().uuid(), - email: zod.string().email(), - role: zod.enum(['admin', 'member', 'viewer']), - assigneeId: zod.string().uuid().optional(), - }), - tags: zod.array(zod.string().min(1).max(64)).min(1).max(50), - occurrences: zod.array(occurrenceZod).min(1).max(100), - attributes: zod.record(zod.string(), zod.string().min(1).max(512)), -}); - -const occurrenceJoi = joi.object({ - at: joi.string().pattern(ISO_DATETIME).required(), - count: joi.number().integer().min(1).max(1_000_000).required(), - requestUrl: joi.string().uri().required(), + tags: zod.array(zod.string()).min(1), }); const joiSchema = joi.object({ - id: joi.string().uuid().required(), - appId: joi.string().uuid().required(), - occurredAt: joi.string().pattern(ISO_DATETIME).required(), - severity: joi.string().valid('debug', 'info', 'warn', 'error').required(), - message: joi.string().min(1).max(10_000).required(), - fingerprint: joi.string().min(1).max(256).required(), - source: joi.object({ - service: joi.string().min(1).max(128).required(), - environment: joi.string().min(1).max(64).required(), - host: joi.string().min(1).max(253).required(), - release: joi.string().pattern(SEMVER).required(), - region: joi.string().min(1).max(32).required(), + name: joi.string().min(1).max(100).required(), + email: joi.string().email().required(), + age: joi.number().min(0).max(150).required(), + phone: joi.string().pattern(/^\(\d{3}\) \d{3}-\d{4}$/).required(), + address: joi.object({ + street: joi.string().min(1).required(), + city: joi.string().min(1).required(), + state: joi.string().length(2).required(), + zip: joi.string().pattern(/^\d{5}$/).required(), }).required(), - actor: joi.object({ - id: joi.string().uuid().required(), - email: joi.string().email().required(), - role: joi.string().valid('admin', 'member', 'viewer').required(), - assigneeId: joi.string().uuid(), - }).required(), - tags: joi.array().items(joi.string().min(1).max(64)).min(1).max(50).required(), - occurrences: joi.array().items(occurrenceJoi).min(1).max(100).required(), - attributes: joi.object().pattern(joi.string(), joi.string().min(1).max(512)).required(), -}); - -const occurrenceYup = yup.object({ - at: yup.string().matches(ISO_DATETIME).required(), - count: yup.number().integer().min(1).max(1_000_000).required(), - requestUrl: yup.string().url().required(), + tags: joi.array().items(joi.string()).min(1).required(), }); const yupSchema = yup.object({ - id: yup.string().uuid().required(), - appId: yup.string().uuid().required(), - occurredAt: yup.string().matches(ISO_DATETIME).required(), - severity: yup.string().oneOf(['debug', 'info', 'warn', 'error']).required(), - message: yup.string().min(1).max(10_000).required(), - fingerprint: yup.string().min(1).max(256).required(), - source: yup.object({ - service: yup.string().min(1).max(128).required(), - environment: yup.string().min(1).max(64).required(), - host: yup.string().min(1).max(253).required(), - release: yup.string().matches(SEMVER).required(), - region: yup.string().min(1).max(32).required(), - }).required(), - actor: yup.object({ - id: yup.string().uuid().required(), - email: yup.string().email().required(), - role: yup.string().oneOf(['admin', 'member', 'viewer']).required(), - assigneeId: yup.string().uuid().optional(), - }).required(), - tags: yup.array().of(yup.string().min(1).max(64)).min(1).max(50).required(), - occurrences: yup.array().of(occurrenceYup).min(1).max(100).required(), - attributes: yup.object().test('string-record', 'attributes must be a string map', (value) => { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return false; - } - return Object.values(value).every((entry) => typeof entry === 'string' && entry.length >= 1 && entry.length <= 512); + name: yup.string().min(1).max(100).required(), + email: yup.string().email().required(), + age: yup.number().min(0).max(150).required(), + phone: yup.string().matches(/^\(\d{3}\) \d{3}-\d{4}$/).required(), + address: yup.object({ + street: yup.string().min(1).required(), + city: yup.string().min(1).required(), + state: yup.string().length(2).required(), + zip: yup.string().matches(/^\d{5}$/).required(), }).required(), + tags: yup.array().of(yup.string()).min(1).required(), }); const libraries = [ @@ -267,11 +129,11 @@ const libraries = [ function assertFixtures(librariesToCheck) { for (const {name, validate} of librariesToCheck) { - if (!validate(validPayload)) { - throw new Error(`${name}: validPayload should pass validation`); + if (!validate(validUser)) { + throw new Error(`${name}: validUser should pass validation`); } - if (validate(invalidPayload)) { - throw new Error(`${name}: invalidPayload should fail validation`); + if (validate(invalidUser)) { + throw new Error(`${name}: invalidUser should fail validation`); } } } @@ -288,11 +150,11 @@ function benchmarkLibrary({name, version, validate}, iterations) { console.log(`\nBenchmarking ${name}@${version}...`); for (let i = 0; i < 1000; i++) { - validate(validPayload); + validate(validUser); } - const validMs = timeRuns(validate, validPayload, iterations); - const invalidMs = timeRuns(validate, invalidPayload, iterations); + const validMs = timeRuns(validate, validUser, iterations); + const invalidMs = timeRuns(validate, invalidUser, iterations); const totalMs = validMs + invalidMs; const result = { @@ -315,20 +177,19 @@ function benchmarkLibrary({name, version, validate}, iterations) { console.log('Validation library benchmark'); console.log('============================'); -console.log('Scenario: API event-ingestion payload (nested objects, enums, UUIDs, URL arrays, string map)'); console.log('Versions tested:'); libraries.forEach(({name, version}) => { console.log(` ${name} ${version}`); }); console.log(`Node ${process.version}`); console.log(''); -console.log(`Each library validates the same payload ${ITERATIONS.toLocaleString()} times with valid data,`); +console.log(`Each library validates the same user object ${ITERATIONS.toLocaleString()} times with valid data,`); console.log(`then ${ITERATIONS.toLocaleString()} times with invalid data (${(ITERATIONS * 2).toLocaleString()} runs total).`); console.log('Invalid runs collect all field errors (abortEarly: false for Joi/Yup).'); console.log('Lower total time is faster. See docs/benchmark.md for methodology and per-run breakdown.'); assertFixtures(libraries); -console.log('\nFixture check passed: validPayload passes and invalidPayload fails for every library.'); +console.log('\nFixture check passed: validUser passes and invalidUser fails for every library.'); const results = libraries.map((lib) => benchmarkLibrary(lib, ITERATIONS)); results.sort((a, b) => a.totalMs - b.totalMs); @@ -346,7 +207,6 @@ results.forEach((result, index) => { const payload = { runAt: new Date().toISOString(), nodeVersion: process.version, - scenario: 'api-event-ingestion', iterations: ITERATIONS, runsPerLibrary: ITERATIONS * 2, results, diff --git a/docs/benchmark.md b/docs/benchmark.md index 7e508ba..a03554a 100644 --- a/docs/benchmark.md +++ b/docs/benchmark.md @@ -4,14 +4,12 @@ This page explains exactly what the runtyp performance numbers measure and how t ## What we measure -Each library validates the **same API event-ingestion payload** — the kind of structured JSON an observability API might accept on ingest: +Each library validates the **same nested user object** under the same rules: -- top-level UUIDs, ISO timestamps, enums, and bounded strings -- nested `source` and `actor` objects (service metadata + user context) -- optional fields (`assigneeId`) -- `tags` string array with length bounds -- `occurrences` array of objects (timestamp, count, request URL) -- `attributes` open string map (`Record`) +- top-level strings (name, email, phone) +- numeric range (age) +- nested address object (street, city, state, zip) +- string array with minimum length (tags) For every library we run **two passes**: @@ -20,7 +18,7 @@ For every library we run **two passes**: That is **200,000 validation runs per library**. Results are reported as **total wall-clock time** for each pass and combined. Lower is faster. -Before timing, the script asserts that `validPayload` passes and `invalidPayload` fails for **every** library. If fixtures drift out of equivalence, the benchmark exits with an error instead of publishing misleading numbers. +Before timing, the script asserts that `validUser` passes and `invalidUser` fails for **every** library. If fixtures drift out of equivalence, the benchmark exits with an error instead of publishing misleading numbers. Each library uses its normal non-throwing validation path where one exists (`runtyp` result object, Zod `safeParse`, Joi `validate`, Yup `validateSync` in try/catch). On invalid input, **all libraries collect every field error** — Joi and Yup use `abortEarly: false` so they validate the same depth as runtyp and Zod. @@ -41,56 +39,39 @@ Environment: Node **v24.16.0** Ā· run date **2026-08-02** Ā· [`benchmark.js`](.. | Library | Version | Valid (100k runs) | Invalid (100k runs) | **Total (200k runs)** | vs runtyp | |---------|---------|-------------------|---------------------|----------------------|-----------| -| **runtyp** | 1.0.0 | 115 ms | 295 ms | **410 ms** | fastest | -| **joi** | 18.2.3 | 846 ms | 1,788 ms | **2,634 ms** | 6.4Ɨ slower | -| **zod** | 4.4.3 | 261 ms | 3,554 ms | **3,815 ms** | 9.3Ɨ slower | -| **yup** | 1.7.1 | 2,413 ms | 33,368 ms | **35,781 ms** | 87Ɨ slower | +| **runtyp** | 1.0.0 | 36 ms | 61 ms | **97 ms** | fastest | +| **joi** | 18.2.3 | 293 ms | 608 ms | **901 ms** | 9.3Ɨ slower | +| **zod** | 4.4.3 | 56 ms | 1,333 ms | **1,389 ms** | 14.3Ɨ slower | +| **yup** | 1.7.1 | 878 ms | 14,959 ms | **15,837 ms** | 163Ɨ slower | ### Per-run averages | Library | Version | Avg valid pass | Avg invalid pass | |---------|---------|----------------|------------------| -| **runtyp** | 1.0.0 | 0.0012 ms | 0.0029 ms | -| **joi** | 18.2.3 | 0.0085 ms | 0.0179 ms | -| **zod** | 4.4.3 | 0.0026 ms | 0.0355 ms | -| **yup** | 1.7.1 | 0.0241 ms | 0.3337 ms | +| **runtyp** | 1.0.0 | 0.0004 ms | 0.0006 ms | +| **joi** | 18.2.3 | 0.0029 ms | 0.0061 ms | +| **zod** | 4.4.3 | 0.0006 ms | 0.0133 ms | +| **yup** | 1.7.1 | 0.0088 ms | 0.1496 ms | -We use the latest pinned competitor versions and Zod's `safeParse` API for a fair comparison. +Zod **4.4.3** is substantially faster on invalid data than older benchmark runs against Zod 4.1.x (combined total dropped from ~3.6 s to ~1.4 s). We use the latest pinned competitor versions and Zod's `safeParse` API for a fair comparison. ## Test schema All libraries express equivalent constraints. Example (runtyp): ```typescript -const occurrence = p.object({ - at: p.regex(ISO_DATETIME, 'must be ISO-8601 UTC datetime'), - count: p.number({range: {min: 1, max: 1_000_000}}), - requestUrl: p.url(), -}); - p.object({ - id: p.uuid(), - appId: p.uuid(), - occurredAt: p.regex(ISO_DATETIME, 'must be ISO-8601 UTC datetime'), - severity: p.enumValue(Severity), - message: p.string({len: {min: 1, max: 10_000}}), - fingerprint: p.string({len: {min: 1, max: 256}}), - source: p.object({ - service: p.string({len: {min: 1, max: 128}}), - environment: p.string({len: {min: 1, max: 64}}), - host: p.string({len: {min: 1, max: 253}}), - release: p.regex(SEMVER, 'must be semver'), - region: p.string({len: {min: 1, max: 32}}), - }), - actor: p.object({ - id: p.uuid(), - email: p.email(), - role: p.enumValue(Role), - assigneeId: p.optional(p.uuid()), + name: p.string({len: {min: 1, max: 100}}), + email: p.email(), + age: p.number({range: {min: 0, max: 150}}), + phone: p.regex(/^\(\d{3}\) \d{3}-\d{4}$/, 'must be valid phone format'), + address: p.object({ + street: p.string({len: {min: 1}}), + city: p.string({len: {min: 1}}), + state: p.string({len: {min: 2, max: 2}}), + zip: p.regex(/^\d{5}$/, 'must be 5 digits'), }), - tags: p.array(p.string({len: {min: 1, max: 64}}), {len: {min: 1, max: 50}}), - occurrences: p.array(occurrence, {len: {min: 1, max: 100}}), - attributes: p.record(p.string({len: {min: 1, max: 512}})), + tags: p.array(p.string(), {len: {min: 1}}), }); ``` diff --git a/package.json b/package.json index 4341a9f..2bbc872 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "runtyp", "version": "1.0.0", - "description": "Lightning-fast, zero-dependency runtime validation for TS/JS. ~9x faster and 15x smaller than zod (4kb vs 60kb gzipped).", + "description": "Lightning-fast, zero-dependency runtime validation for TS/JS. ~14x faster and 15x smaller than zod (4kb vs 60kb gzipped).", "keywords": [ "joi", "zod",