Skip to content

perf: hot-path overhaul, zero runtime deps, full quality/docs pass - #46

Merged
parkrevil merged 10 commits into
mainfrom
perf/hot-paths
Jul 15, 2026
Merged

perf: hot-path overhaul, zero runtime deps, full quality/docs pass#46
parkrevil merged 10 commits into
mainfrom
perf/hot-paths

Conversation

@parkrevil

Copy link
Copy Markdown
Contributor

Summary

Performance overhaul of the deserialize hot paths plus a full-repo quality and documentation audit (minor, 6.0.0 → 6.1.0). 10 commits, each adversarially reviewed (Codex + Grok; 10 review rounds total across spec/implementation/quality) with all findings fixed pre-commit.

Performance (measured, mitata medians)

  • invalid-path deserialize ~2× (simple 102→56ns, nested 306→158ns, error-collection 102→59ns)
  • nested-array deserialize ~1.7× (1000 items 16.4→9.4µs); valid paths 25-50% faster
  • per-request groups validation ~15×; sync transforms 1.3-1.8×
  • Mechanisms: raw-BakerIssue[] internal failure protocol (Array.isArray sentinel), lazy error-list allocation, preallocated cursor output arrays, executor-object hoisting, allocation-free checkCallOptions, inlined transform Promise guard

Consumer-facing

  • Zero runtime dependencies (@zipbul/result removed — was internal-only)
  • New seal-time guard: DTO classes extending Array throw BakerError at seal (protocol soundness; noted in the changeset as a behavior change)
  • EmittableRule/WidenLiteral importable from @zipbul/baker/rules (fixes real TS2305/TS2742 consumer repros)
  • README: full rule surface (3 new category sections), all BakerError causes, zero-deps claim, error-code list completed (circular, isDefined)

Quality

  • 38 copy-pasted regex-rule bodies → makeRegexRule; 10+ builder duplications extracted into shared emit helpers; serialize generateFieldCode SRP split; provably-dead branches and lost-narrowing non-null assertions removed — all with byte-identical generated code (15 codegen snapshots unchanged throughout)
  • The long-standing untestable missing-peer branch is now covered (thunk-based loadPeerDependency + spec)

Verification

  • 2496 tests green, per-file 90% coverage gate met, tsgo 0, oxlint 0/0, knip clean, test:memory pass
  • Generated-code byte-identity machine-verified via snapshots; non-snapshot DTO shapes (17 executor dumps) verified identical old-vs-new by independent review
  • Built dist smoke-tested; consumer-side type probes pass against the built package

🤖 Generated with Claude Code

parkrevil and others added 10 commits July 12, 2026 13:58
… directory

Running bench/class-validator/*.cv.bench.ts from the repo root crashes: bun resolves
tsconfig from the working directory, so the root tsconfig (native TC39 decorators)
applies and class-validator's legacy-decorator API throws in ValidateBy. Add
`bench` / `bench:cv` scripts — the cv runner cds into bench/class-validator so its
own tsconfig (experimentalDecorators) applies — and a bench/README.md documenting
the two suites and the cwd caveat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…reallocated element loops

Replace the @zipbul/result err()/isErr() protocol in generated deserialize executors
with the raw BakerIssue[] array as the failure value, discriminated by Array.isArray —
a success is always a class instance and seal now rejects Array-exotic DTO classes
(new BakerError at seal: "DTO classes must not extend Array"), making the sentinel
sound including under allowClassDefaults' `new Cls()` path. err() cost 48.5ns per wrap
(alloc + Object.freeze) — 55% of the whole invalid path; validate executors already
used a null-sentinel, deserialize now matches. @zipbul/result is removed entirely
(zero runtime dependencies).

Element-loop machinery, via centralized emit helpers (emitErrPush/emitMarkDecl/
emitMarkCheck — no raw push/length emission sites remain):
- lazy error lists: `errors = null`, allocated on first push — no allocation on the
  valid path, and no per-element allocation in nested loops
- nested-[Dto] output arrays preallocated with a write cursor instead of push-growth
- nested executor objects hoisted out of element loops (object hoist, preserving the
  method receiver)

Behavior-preserving: issue payloads, codes, paths, and ordering are identical; only
generated-code text changed (snapshots updated and hunk-verified). Adversarially
reviewed (Codex + Grok, spec and implementation rounds), empirically probed on
all-valid/all-invalid/mixed/empty/stopAtFirstError/autoConvert/circular/cache-reuse
paths.

Benchmarks (Bun 1.3.14, mitata, median):
- simple valid 44.5→32.9ns, invalid 102→55.9ns
- nested valid 41.7→20.4ns, invalid 306→157.6ns
- array-1000 deserialize 16.4→9.4µs
- error-collection 102→59.4ns

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With a groups object the per-call options guard cost ~62ns — dominated by the
Object.keys() array allocation and the groups.some() closure — on the documented
per-request groups pattern. Rewrite the loop as `for...in` guarded by
Object.hasOwn (inherited enumerables from Object.prototype pollution are filtered,
preserving exact Object.keys membership and order) and validate groups with an
indexed loop whose `i in groups` hole-check matches Array.prototype.some's
HasProperty semantics, so sparse arrays stay accepted.

Semantics and every BakerError message are byte-identical; the undefined fast path
is untouched. ~1.7x faster (6.9→4.1ns in a same-process A/B), zero allocation.
Regression tests cover prototype pollution (own-key behavior preserved both ways)
and sparse groups arrays.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rated code

Delete FieldMetaApplier's #wrapTransform runtime closure: TransformDef now stores the
user's raw transform fn (isAsync still detected at decoration via isAsyncFunction), and
the "sync transform returned Promise" BakerError guard is emitted as inline generated
code after each sync transform call — async transforms emit no guard at all. This
removes two extra call frames (refs[N] → wrapper → user fn) plus an isPromiseLike()
call per transform per direction. BakerError joins the deserialize executor's closure
params (serialize-builder already had it).

Codegen simplifications that fall out: the hand-unrolled 1-/2-transform special cases
collapse into the general statement-per-transform loop, and serialize's
buildTransformExpr emits statement-per-transform (with per-sync-step guards into a
reused per-field temp var) whenever a chain contains a sync transform — pure-async
chains keep the nested-expression form.

The per-transform await/guard decision now uses `td.isAsync ?? isAsyncFunction(td.fn)`,
matching AsyncAnalyzer's established fallback — previously the codegen read the bare
flag, silently skipping `await` for off-decorator metadata that omitted isAsync.

Behavior-preserving: guard predicate ≡ isPromiseLike, messages byte-identical, throw
timing identical at every chain position in both directions (new mid-chain e2e tests).
Adversarially reviewed (Codex + Grok): zero surviving findings, including old-vs-new
async-detection equivalence and serialize's reversed execution order.

Benchmarks: deserialize 1/2 sync transforms 13.7→12.4 / 17.1→13.9ns;
serialize 1/2 sync transforms 16.9→9.4 / 25.2→14.2ns. Plain fields unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… causes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…c type exports

Full-repo quality audit follow-up (three parallel audits: per-directory code sweep,
docs-vs-source, public-surface consistency). All changes are behavior-preserving —
generated code is byte-identical (zero snapshot changes).

Deduplication:
- makeRegexRule() in rules/string-shared.ts replaces 38 copy-pasted single-regex
  "test-then-fail" rule bodies across 8 string-rule files
- resolveNestedExecutor / resolveFieldSkip (direction-parameterized) / emitPromiseGuard
  extracted to seal/codegen-utils.ts — each was duplicated between the deserialize and
  serialize builders
- loadPeerDependency shared by the luxon/moment transformers; its thunk-based shape
  finally makes the missing-peer catch testable (new 1:1 spec, previously a documented
  untestable branch)

Type strictness / SSOT:
- rules/public.ts now exports EmittableRule and WidenLiteral (verified consumer repros:
  TS2305 importing EmittableRule from @zipbul/baker/rules; TS2742 naming WidenLiteral in
  a generic wrapper around equals/isIn); WidenLiteral also exported from the root
- typedBuckets typed via a single TYPE_GATE_KINDS tuple (drops two non-null assertions)
- FieldOptions.message reuses MessageArgs; TypeThunk shared by TypeDef.fn and
  FieldOptions.type (drops a cast); redundant casts removed (binary.ts, type-normalizer);
  meta-store avoids re-reading [Symbol.metadata] with a non-null assertion

Docs-in-code:
- errors.ts reserved-code list gains the emitted-but-undocumented 'circular' and
  'isDefined'; isBakerIssueSet example uses a Baker instance; stale/misleading comments
  reworded (seal-time vs runtime, dead-guard phrasing); resolvedClass invariant documented

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rError causes

README accuracy pass from the docs-vs-source audit — every added claim verified
against the source:

- Rules: add the missing Combinators (oneOf/arrayEvery), Object (isNotEmptyObject/
  isInstance), and Binary (isUint8Array/isByteSize) sections; complete the Type
  Checkers list (isRegExp/isFunction/isStatelessRegExp); add a curated-highlights
  caveat under the "114 built-in rules" heading pointing at @zipbul/baker/rules as
  the full surface
- Error handling: expand the BakerError causes to match errors.ts (including the new
  extends-Array seal-time rejection and why), and note that the luxon/moment
  transformers throw BakerError at await when the peer is missing
- Zero runtime dependencies: claimed in the README intro, the Why-baker table, and
  package.json's description. The comparison table's stale "reflect-metadata:
  Required" row for class-validator was replaced with an accurate Dependencies row —
  class-validator 0.15 depends on validator + libphonenumber-js, not reflect-metadata
  (the FAQ line repeating that claim is fixed too)
- Config example gains the missing `debug` key; the @field options table gains the
  `rules` row; Exports lists ArrayOfMarker and the /symbols subpath; Map wording and
  union-domain rules (isLatitude/isLongitude) clarified; Luxon/MomentTransformerOptions
  mentioned

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…owing assertions

Final pass from the deep codegen-builder audit. Generated code is byte-identical —
all 15 codegen snapshots unchanged.

Emission single-sourcing (deserialize-codegen.ts):
- emitFailStmt — the collectErrors push-vs-return split, previously hand-rolled at ~10
  sites, completing the error-emission helper family
- emitInvalidDiscriminatorDefault (4 near-identical default arms),
  emitValidateElement (3 copy-pasted validate-only element blocks),
  emitMarkedAssignTail (3 duplicated mark/assign tails), emitGroupsGuardPair
- generateDiscriminatorEachCode and its ValidateOnly twin merged into one
  method-parameterized core; resolveTypeGate moved beside its analysis-phase siblings
  as a pure function

Structure/strictness:
- serialize-builder's 234-line generateFieldCode split into collection/discriminator/
  nested methods, mirroring the deserialize side
- provably-dead `if (!meta.type)` branches removed (the sole call site guards the
  narrowed TypeDef, now passed as a parameter)
- lost-narrowing non-null assertions eliminated by threading narrowed values
  (meta.type/collection/discriminator params, an InlineTarget struct instead of a
  derived boolean); generateConversionCode derives its mode from skipVar, closing a
  skipVar=null+collectErrors=true footgun; lazy Set init via ??=
- change-history narration comments replaced with current-contract statements

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…uous

The rejects assertion was unawaited and the .catch-based assertions would pass
vacuously if the promise resolved. Await the outcome explicitly and fail loud when
the loader unexpectedly resolves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@parkrevil
parkrevil merged commit 69073bf into main Jul 15, 2026
1 check passed
@parkrevil
parkrevil deleted the perf/hot-paths branch July 15, 2026 02:31
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.

1 participant