Skip to content

perf+types: hoist per-request invariants and tighten lib/index.d.ts#273

Open
arb wants to merge 3 commits into
masterfrom
optimizations
Open

perf+types: hoist per-request invariants and tighten lib/index.d.ts#273
arb wants to merge 3 commits into
masterfrom
optimizations

Conversation

@arb

@arb arb commented May 8, 2026

Copy link
Copy Markdown
Owner

Two-commit branch out of an audit of lib/* for non-idiomatic JS, perf, and type-definition tightness. Both commits are independently revertable; the perf one is the only behavior change.

What's in here

1. perf: hoist per-request invariants out of celebrate() middleware (c3dba2a)

Most of what celebrate() was doing per request is fully determined the moment the middleware is constructed. This commit moves that work out of the closure:

  • The mode -> impl lookup (internals.validateFns[finalOpts.mode]) is now resolved once at construction.
  • The two passes that previously walked Object.entries(_requestRules) to compile and then internals.REQ_VALIDATIONS to filter are merged into a single iteration over the canonical segment order, dropping the intermediate Map.
  • When reqContext is falsy (the default and common case), joiConfig and the bound steps array are precomputed once. Per request the cached steps is reused directly. When reqContext is true, we still rebuild steps per request because joiConfig.context must reference req.

Behavior is unchanged: same canonical validation order, same CelebrateError.details shape, same Object.defineProperty semantics on req, same middleware._schema exposure, same return-promise-for-tests contract. All 62 tests still pass with 100% coverage retained.

utils/benchmark.js deltas (avg of 3 runs each, throughput ops/s):

Scenario Baseline (master) Candidate Δ
valid (POST, body-only schema, partial mode) 1,215,325 1,378,617 +13.4%
invalid partial (POST, body-only schema, fails) 238,711 245,241 +2.7%
invalid full (POST, body+query+params, full mode, all fail) 103,842 106,556 +2.6%

The valid path benefits most because the hoist removes the dominant non-Joi setup cost on a successful request. The invalid paths gain less because Joi's rejection path (building ValidationError, walking details) dominates total time, so the saved setup overhead is a smaller share. reqContext: true callers see no regression — that path is unchanged.

2. types: tighten lib/index.d.ts to match runtime (7bd8e16)

Five small fixes to the hand-written declaration file. No runtime code is touched.

  • errors() opts shape — was { statusCode: number } (statusCode wrongly required, message missing). Now { statusCode?: number; message?: string }, matching ERRORSOPTSSCHEMA in lib/schema.js and the runtime defaulting in lib/celebrate.js.
  • errors() generics removed<P, ResBody, ReqBody, ReqQuery> were forwarded to ErrorRequestHandler but never narrowed anything because error middleware doesn't differentiate request types.
  • Modes / Segments — switched from declare enum to declare const + type alias. Matches the runtime shape (plain const objects in lib/constants.js), gives consumers literal-string types instead of nominal enum-member types, and lets comparisons like seg === 'params' type-check cleanly under strict configs.
  • SchemaOptions segments — retyped from object to Joi.SchemaLike. Joi 18 ships SchemaLike for exactly this case and Joi.compile already accepts both compiled Joi.Schema and plain object schemas at runtime.
  • Celebrator2 — made generic with the same defaults as Celebrator1 and Celebrator, so generics propagate through the full celebrator(opts)(joiOpts)(schema) curry chain. Previously the third call returned a non-generic RequestHandler and dropped any narrowing the consumer had set up.

Externally visible to consumers in three places: anyone calling errors<MyParams>(...) will need to drop the type args; anyone using Segments/Modes enum-member types in type position (e.g. function f(s: Segments.PARAMS)) gets the literal string instead; anyone passing a non-SchemaLike value into SchemaOptions segments will now see a type error. No runtime impact in any case.

Verification

  • CI=true npm test — 62/62 passing, 100% coverage retained on every lib/*.js file.
  • npx eslint . — clean.
  • node_modules/.bin/tsc --noEmit -p tmp/agent/tsconfig.json — clean against a throwaway consumer smoke-test exercising every public export, including @ts-expect-error lines for negative cases (kept in tmp/agent/, not committed).
  • node_modules/.bin/tsc --noEmit ... lib/index.d.ts standalone — clean.
  • utils/benchmark.js perf comparison documented above.

Matrix safety

CI matrix in .github/workflows/ci.yml is Node 20.x, 22.x, 24.x, 25.x × Express latest-4, latest. Both commits work across the full matrix:

  • Perf commit: same primitives the original used (Map, Array.prototype.map, object spread, async/await). The .then(next).catch(next) is preserved because Express 4 doesn't auto-forward async-middleware rejections (Express 5 does, but we support both).
  • Types commit: pure .d.ts declarations, zero runtime effect.

QA Spec

  • All Node × Express matrix cells pass on CI
  • lib/index.d.ts consumers in your downstream apps (if any TS) still type-check; fix-ups limited to the three externally-visible items above
  • No regression in reqContext: true flows (covered by existing tests; benchmark only exercises reqContext: false)
  • errors() default behavior unchanged: 400 statusCode, joi error message, validation map keyed by segment
  • celebrator curried call shapes — all four — still type-check at consumer call sites

Future cleanup spotted in the audit (not in this PR)

  • Object.defineProperty(req, segment, { value }) in lib/celebrate.js seals the validated segment as non-writable / non-configurable / non-enumerable. Documented behavior is "overwrite", not "seal" — chained celebrate() calls on the same segment throw TypeError: Cannot redefine property. Worth either documenting or loosening the descriptor.
  • lodash.curry + lodash.flip could be replaced with ~8 lines of native code on Node 20+.
  • EscapeHtml on JSON response keys is unusual for application/json APIs; defense-in-depth, but worth a deliberate decision.

Made with Cursor

arb and others added 3 commits May 5, 2026 08:53
Move work that's fully determined at celebrate() call time out of the
per-request middleware closure:

- Look up validateRequest (mode -> impl) once at construction.
- Merge the Joi.compile pass with the canonical-segment-order walk into
  a single iteration over internals.REQ_VALIDATIONS, dropping the
  intermediate Map.
- When reqContext is falsy (the default and common case), precompute
  joiConfig and the bound steps once. Per request, reuse the cached
  steps array directly. When reqContext is true, fall back to building
  steps per request because joiConfig.context must reference req.

Behavior is unchanged: same validation order, same error shapes, same
req-mutation semantics, same middleware._schema exposure, same
return-promise-for-tests contract. All 62 tests pass with 100%
coverage retained.

utils/benchmark.js comparison (avg of 3 runs each, throughput ops/s):

  valid           1,215,325 -> 1,378,617  (+13.4%)
  invalid partial   238,711 ->   245,241   (+2.7%)
  invalid full      103,842 ->   106,556   (+2.6%)

Latency averages move in lockstep:

  valid             816.7ns ->   743.6ns   (-8.9%, steady-state)
  invalid partial 4,308.1ns -> 4,201.6ns   (-2.5%)
  invalid full    9,872.9ns -> 9,639.3ns   (-2.4%)

The "valid" path benefits most because the hoist eliminates the
dominant non-Joi setup cost on a successful request. The invalid
paths gain less because Joi's rejection path (building
ValidationError, walking details) dominates total time, so the saved
setup overhead is a smaller share.

Co-authored-by: Cursor <cursoragent@cursor.com>
Five small fixes to the hand-written declaration file. No runtime code
is touched. Verified with tsc --noEmit against a throwaway consumer
smoke-test plus the existing test suite (62/62 passing, 100% coverage
retained) and the existing eslint pass.

- errors() opts type now accepts { statusCode?: number; message?: string }
  to match the runtime schema (lib/celebrate.js, ERRORSOPTSSCHEMA in
  lib/schema.js). Previously statusCode was wrongly required and message
  was missing entirely. Externally visible.

- errors() generics removed. The <P, ResBody, ReqBody, ReqQuery>
  parameters were forwarded to ErrorRequestHandler but never narrowed
  anything because error middleware doesn't differentiate request types.
  Externally visible to callers writing errors<MyParams>(...).

- Modes and Segments switched from `declare enum` to `declare const` +
  type alias. This matches the runtime shape (plain const objects in
  lib/constants.js) and gives consumers literal-string types instead of
  nominal enum-member types. Comparisons like `seg === 'params'` that
  previously hit "no overlap" errors under strict configs now type-check
  cleanly. Externally visible.

- SchemaOptions segments retyped from `object` to `Joi.SchemaLike`.
  Joi 18 ships SchemaLike for exactly this case. Runtime Joi.compile
  already accepts both compiled Joi.Schema and plain object schemas,
  so no behavior change.

- Celebrator2 made generic with the same defaults as Celebrator1 and
  Celebrator, so generics propagate through the full
  celebrator(opts)(joiOpts)(schema) curry chain. Previously the third
  call returned a non-generic RequestHandler and dropped any narrowing
  the consumer had set up.

Co-authored-by: Cursor <cursoragent@cursor.com>
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (d1a75e6) to head (7bd8e16).
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.

Additional details and impacted files
@@            Coverage Diff            @@
##            master      #273   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            5         5           
  Lines          263       265    +2     
=========================================
+ Hits           263       265    +2     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@arb arb marked this pull request as ready for review May 8, 2026 21:32
@arb arb modified the milestones: 16.0.0, 16.0.1 May 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants