validator.js's string validators carry the highest risk — 5 to fix first

A structural analysis of validatorjs/validator.js finds five dormant, high-complexity validators — led by isURL at cyclomatic complexity 58 — sitting untouched for months as latent refactoring debt.

Stephen Collins ·
Generated by hotspots · free & open source
pip
$ pip install hotspots-cli
Activity Risk16.97Low
Hottest FunctionisURL

Antipatterns Detected

exit_heavy6complex_branching5god_function4stale_complex4deeply_nested3long_function2hub_function1

Run this on your own codebase

See if your own repo has a isURL-style hotspot — run this in any local git repo:

pip
$ pip install hotspots-cli
npm
$ npm install -g @stephencollinstech/hotspots
Run in any repo
$ hotspots analyze .
★ Star on GitHub

Key Points

What is exit_heavy and why does it matter in validator.js?

Exit-heavy means a function has multiple return or exit points scattered through its body rather than a single, predictable exit path. It matters because every additional return statement is another path a test suite has to cover explicitly, and it's easy to miss a case when reasoning about what the function returns under a given input. It's the most common pattern across validator.js's top hotspots, appearing 6 times, including in isURL, isEmail, and isDate — all functions built around a sequence of early-return validation checks.

How do I reduce cyclomatic complexity in JavaScript validators?

The most direct technique is extract-method: pull self-contained conditional blocks — a domain-specific branch, a format-parsing routine, a disambiguation check — into their own named functions so each one carries a smaller, independently testable slice of the logic. A cyclomatic complexity above 15 is a reasonable trigger to start looking for extraction candidates, and above 30 warrants doing it now rather than later. isURL, at a cyclomatic complexity of 58, is the clearest candidate in this dataset — extracting the protocol-vs-auth disambiguation logic alone would likely cut its complexity by close to half.

Is validator.js actively maintained?

Yes, but the activity is concentrated: the isTaxID.js checks (dkDkCheck, lvLvCheck, plPlCheck) were each committed to once in the last 30 days and were last changed 15 to 17 days ago — real, recent development. The five functions in my top-5 table tell a different story — none has seen a commit in the last 30 days, with isMobilePhone the most dormant at 784 days since its last change. Active development and structural debt coexist here: the tax-ID validators are getting attention while the older, larger URL and email validators sit untouched.

How do I reproduce this analysis?

The analysis was run with the hotspots CLI against commit a79ff98. Check out that commit and run `hotspots analyze . --mode snapshot --explain-patterns --force` — the same command works on any local git repository without extra configuration.

What does activity-weighted risk mean?

Activity-weighted risk multiplies structural complexity — cyclomatic complexity times nesting depth times fan-out — by recent commit frequency, so functions that are both hard to understand and actively changing score highest. A function with cyclomatic complexity 58 that hasn't been touched in 264 days, like isURL, still scores high because its structure alone is risky, but a function with lower complexity that's edited weekly can outrank a dormant, more tangled one if the near-term change probability is high enough. The point is to prioritize refactoring where a bug is most likely to be introduced next, not just where the code looks most complicated on paper.

validator.js spans 204 analyzed functions, 30 of them landing in the critical band, and the top risk isn’t a function under active churn — it’s isURL, which hasn’t been modified in 264 days but still carries a cyclomatic complexity of 58, a fan-out of 27, and an activity-weighted risk of 16.97. All five functions in my top-5 table fall into the ‘debt’ quadrant: zero commits in the last 30 days, but structural complexity high enough that the next person who changes one of them takes on real blast radius with no recent context to lean on. I’d start with isURL, not because it’s on fire, but because it’s the largest, most tangled piece of debt sitting closest to the top of the pile.

The table below ranks functions by activity-weighted risk — a score that multiplies structural complexity by recent commit frequency. A function that is both hard to understand (high cyclomatic complexity) and actively changing is a higher priority than one that is complex but untouched. CC = cyclomatic complexity (independent execution paths); ND = max nesting depth; FO = fan-out (distinct callees).

Top 5 Hotspots

FunctionFileRiskCCNDFO
isURLsrc/lib/isURL.js17.058527
isEmailsrc/lib/isEmail.js15.037329
normalizeEmailsrc/lib/normalizeEmail.js14.929614
isMobilePhonesrc/lib/isMobilePhone.js14.61367
isDatesrc/lib/isDate.js14.520315

The shape of the risk

Triage Band Distribution
Fire4Debt68Watch2OK130

204 functions analyzed

Out of 204 functions, 68 fall into the debt quadrant — structurally complex, but not currently being touched. Only 4 sit in the fire quadrant (actively changing and complex), and those 4 live entirely in isTaxID.js, which I cover briefly below. The dominant story in this repo is dormant complexity, not live churn.

Detected Antipatterns
Exit Heavy×6Exit Heavy
Multiple return or throw paths dispersed through the body — each exit needs separate test coverage.
Complex Branching×5Complex Branching
High cyclomatic complexity — many independent execution paths, each a potential bug surface and required test case.
God Function×4God Function
Calls an unusually large number of distinct functions (high fan-out), making it the structural centre of gravity for a subsystem.
Stale Complex×4Stale Complex
High structural complexity but untouched for a long time — structural debt that will bite whoever opens it next.
Deeply Nested×3Deeply Nested
Control structures nested 4+ levels deep, making it hard to reason about the full execution state at inner branches.
Long Function×2Long Function
Function body is too long to review in a single pass; likely contains multiple distinct responsibilities.
Hub Function×1Hub Function
Many other functions call this one — a change here ripples widely through callers.

Exit-heavy is the most common antipattern across the top hotspots (6 occurrences), followed by complex branching (5) and god function (4). That combination — many return points, many execution paths, wide coupling — is what turns a quiet file into a maintenance trap the day someone needs to add a new option flag.

isURL — src/lib/isURL.js

isURL
src/lib/isURL.js
16.97
critical
CC 58
ND 5
FO 27
touches/30d 0

isURL is the top-ranked hotspot in the repo, and it earns that spot on structure alone: cyclomatic complexity of 58 against a fan-out of 27, tagged god_function, long_function, deeply_nested, and exit_heavy. The source shows why — a chain of early-return guards (mailto detection, fragment/query stripping, length checks) followed by an inline protocol-parsing routine that has to disambiguate protocol: from user:password@host auth strings, including a check for URL-encoded content. That’s a lot of decision logic living in one function, and it hasn’t been touched in 264 days, with no distinct authors committing to it in the last 90 days either. The file’s own commit history shows five of its eight total commits were bug fixes — a high historical bug-fix rate. None of that proves today’s code is broken, but it does mean this function has been fragile before. My recommendation: pull the protocol-vs-auth disambiguation logic (the cleanUpProtocol closure and the @// position checks) into a named, independently testable helper before the next feature request forces someone to read all 58 paths at once.

isEmail — src/lib/isEmail.js

isEmail
src/lib/isEmail.js
14.99
critical
CC 37
ND 3
FO 29
touches/30d 0

isEmail carries the highest fan-out of the five, at 29 distinct function calls, against a cyclomatic complexity of 37. Nesting depth is comparatively shallow at 3, which tells me the complexity here is driven by breadth — display-name parsing, host allow/deny lists, Gmail-specific normalization, byte-length checks — rather than deep conditional stacking. It’s tagged exit_heavy, long_function, and god_function, and it hasn’t changed in 632 days. Two of its three recorded commits were bug fixes. The Gmail-specific branch inside this function — dot-stripping, sub-address removal, username-part validation via gmailUserPart — is already a self-contained unit; extracting it into its own named function would cut both the complexity count and the fan-out attributed to isEmail itself.

normalizeEmail — src/lib/normalizeEmail.js

normalizeEmail
src/lib/normalizeEmail.js
14.85
critical
CC 29
ND 6
FO 14
touches/30d 0

normalizeEmail has the deepest nesting of any function in the top five — a max nesting depth of 6 — which lines up with the source: a long if/else-if chain branching on domain (gmail, icloud, outlook.com, yahoo, yandex), each branch with its own nested option checks for subaddress removal and lowercasing. It’s tagged complex_branching and deeply_nested together, the strongest signal in this dataset that the control flow itself is the problem, not just the line count. It’s been dormant for 720 days, but all three of its recorded commits were bug fixes. That’s a small sample, but it’s a 100% hit rate on a function that only gets touched to fix something. The cleanest fix here is decompose-conditional: turn the five domain-specific branches into a lookup table of normalizer functions (normalizeGmail, normalizeIcloud, etc.) keyed by domain, so adding or fixing a provider’s rules doesn’t require re-reading the whole chain.

isMobilePhone — src/lib/isMobilePhone.js

isMobilePhone
src/lib/isMobilePhone.js
14.61
critical
CC 13
ND 6
FO 7
touches/30d 0

isMobilePhone is the outlier in this group: a cyclomatic complexity of 13 is modest next to the others, but its nesting depth of 6 matches normalizeEmail’s, meaning the risk here comes from how deep the control flow goes, not how many branches exist at the top level. The source shows an array-locale path with a nested .some() callback wrapping a property lookup and a regex test — three levels of indirection to answer one boolean question. It’s been untouched for 784 days, the longest dormancy of the five, and its past pull requests drew noticeably more review comments per change than the other functions here, even though only 12 total commits have touched the file. That combination — long dormancy plus a history of dense review conversation — suggests this function is worth a second look even without recent bug-fix commits (about a third of its commits were fixes, the lowest rate of the group). I’d flatten the nested locale-lookup logic into a single loop with an early return instead of the current mixed .some()/for...in structure.

isDate — src/lib/isDate.js

isDate
src/lib/isDate.js
14.47
critical
CC 20
ND 3
FO 15
touches/30d 0

isDate carries the hub_function tag — the only one in this dataset — alongside god_function, exit_heavy, and stale_complex. Fan-out is 15, and the source shows why: format-string parsing, delimiter matching, per-field length validation, two-digit-year expansion logic, and a fallback path for non-string Date objects all live in one function. A hub function means a lot of other code paths likely route through this one, so a change to the year/month/day normalization logic carries more ripple potential than the complexity number alone suggests. It’s been dormant for 657 days, with two of its three recorded commits fixing bugs. My first move would be to extract the two-digit-year expansion (fullYear handling) and the delimiter-matching logic into separate, named functions — both are self-contained blocks that don’t need to see the rest of isDate’s state.

What’s actually active right now

The fire quadrant in this repo is small — 4 functions — and all four sit outside the top-5 table: dkDkCheck, lvLvCheck, and plPlCheck in src/lib/isTaxID.js, plus utf8ByteLength in isByteLength.js. Each was committed to once in the last 30 days and last changed 15 to 21 days ago, meaning tax-ID validation logic is where real development is happening right now. None cracked the top 5 because their structural complexity (cyclomatic complexity 11–23, fan-out 1–7) is lower than the five debt-quadrant functions above — but they’re worth watching precisely because they’re moving targets. If any of them climbs in complexity while still under active edit, that’s the profile that becomes a fire-quadrant top-5 entry next time this analysis runs.

Patterns Found

Antipatterns detected across the top functions in this snapshot:

PatternOccurrences
exit_heavy6
complex_branching5
god_function4
stale_complex4
deeply_nested3
long_function2
hub_function1

These labels belong to two tiers — Tier 1 (structural): complex_branching, deeply_nested, exit_heavy, long_function, god_function. Tier 2 (relational/temporal): hub_function, cyclic_hub, middle_man, neighbor_risk, stale_complex, churn_magnet, shotgun_target, volatile_god.

See more analyses with these patterns: complex_branching, deeply_nested, exit_heavy, god_function, stale_complex.

Reproduce This Analysis

git clone https://github.com/validatorjs/validator.js
cd validator.js
git checkout a79ff980ab14257e795332989e497bdff3218e87
hotspots analyze . --mode snapshot --explain-patterns --force

To run the same analysis on your own codebase, run hotspots analyze . --mode snapshot in any local git repo — no configuration required.

I use Hotspots to highlight structural and activity risk — not “bad code.” I treat these findings as a prioritization aid, not a bug predictor. Editorial policy →

Was this useful? Let me know →

Related Analyses