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..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. 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,18 @@ 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 | +**Versions tested:** runtyp 1.0.0 Ā· joi 18.2.3 Ā· zod 4.4.3 Ā· yup 1.7.1 Ā· Node v24.16.0 -*Benchmark results from 100,000 iterations of complex object validation with nested objects, arrays, and various validation rules. Lower times are better.* +| 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 80293e3..d050151 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,128 @@ 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('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).'); +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..a03554a --- /dev/null +++ b/docs/benchmark.md @@ -0,0 +1,96 @@ +# 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. + +## Versions tested + +All versions are **pinned exactly** in `package.json` and the lockfile: + +| Library | Version | +|---------|---------| +| **runtyp** | 1.0.0 | +| **joi** | 18.2.3 | +| **zod** | 4.4.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 | +|---------|---------|-------------------|---------------------|----------------------|-----------| +| **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.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. + +## 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" } }