Migrating
4.x → 5.x — root entry point removed
Pending — not yet released. This section describes a change parked on an unreleased branch until the next major is cut (#259).
exports["."] is gone from package.json. Before this change, @rtorcato/js-common
resolved from the root to an empty module — import * as m from '@rtorcato/js-common'
succeeded and bound nothing, failing only later at the call site with a confusing
x is not a function. Now the same import throws ERR_PACKAGE_PATH_NOT_EXPORTED
immediately, at the import itself.
The package was always subpath-only in practice; this just makes that enforced rather than half-present.
| Was | Now |
|---|---|
import { x } from '@rtorcato/js-common' | import { x } from '@rtorcato/js-common/<module>' — see Available Modules for which one |
zod, pino, uuid and short-uuid are optional peers
Pending — not yet released (#308).
These four were regular dependencies, so every consumer installed them even when
it only imported ./arrays. Each serves a single module, so they are now optional
peerDependencies: installing @rtorcato/js-common no longer pulls them in, and
the module that needs one fails at import with the missing package's name until you
add it.
| If you import | Add |
|---|---|
@rtorcato/js-common/env | pnpm add zod |
@rtorcato/js-common/logger | pnpm add pino |
@rtorcato/js-common/uuid | pnpm add uuid short-uuid |
Nothing else changes — the same version ranges apply (zod@^4, pino@^10,
uuid@^14, short-uuid@^6), and modules that never used them need nothing.
Deprecated aliases removed
Three aliases deprecated since #239 are deleted (#307). Each already delegated to its replacement, so this is an import swap with no behaviour change.
| Was | Now |
|---|---|
validation.isEmail | isValidEmail from @rtorcato/js-common/emails |
validation.isUrl | isValidUrl from @rtorcato/js-common/url |
os.getOsPlatform | getProcessPlatform from @rtorcato/js-common/process |
addMonths clamps to month end
date.addMonths used to roll over when the source day did not exist in the target
month, so addMonths('2026-01-31', 1) returned March 3. It now clamps to the
last day of the target month, as Temporal and date-fns do
(#290). Only inputs on day 29–31
are affected.
| Call | Was | Now |
|---|---|---|
addMonths('2026-01-31', 1) | 2026-03-03 | 2026-02-28 |
addMonths('2024-01-31', 1) | 2024-03-02 | 2024-02-29 |
addMonths('2026-03-31', -1) | 2026-03-03 | 2026-02-28 |
4.x → 5.x — ./crypto is Web Crypto, and async
Pending — not yet released (#309).
./crypto no longer imports node:crypto; it uses Web Crypto (globalThis.crypto),
so it now runs in browsers and on edge runtimes as well as Node. crypto.subtle is
async, so the two digest helpers now return promises. ./security lost its only
node:crypto import too, and runs anywhere.
| Was | Now |
|---|---|
hashString(s) → string | await hashString(s) → Promise<string> |
hmacHash(s, key) → string | await hmacHash(s, key) → Promise<string> |
hashString(s, 'md5') | Unsupported — Web Crypto has no md5. Use 'sha256', or createHash('md5') from node:crypto if you need md5 for interop |
hashString(s, 'sha3-256') or any other node:crypto name | Only 'sha1', 'sha256', 'sha384', 'sha512' are accepted |
security.generateSecureToken() | randomHex(32) from @rtorcato/js-common/crypto — pass 32 to keep the 64-char length; randomHex() defaults to 16 bytes |
security.generateSecureToken(n) | randomHex(n) from @rtorcato/js-common/crypto |
randomHex, base64Encode and base64Decode keep their signatures. base64Decode
now throws on input that is not standard base64 (for example the URL-safe -/_
alphabet), where Buffer silently accepted it.
3.x → 4.x — what the platform already does
4.0 removes wrappers whose replacement is the runtime itself, not another module in this package. Nothing was re-homed, so there is no "import it from here instead" — every row below points at a JavaScript built-in.
The engines.node floor of >=22 is what makes this possible: Set.prototype
methods, Object.groupBy, Array.prototype.at, structuredClone and
crypto.randomUUID are all available there.
Individual exports removed
| Was | Now |
|---|---|
promises.all(ps) | Promise.all(ps) |
promises.allSettled(ps) | Promise.allSettled(ps) |
promises.race(ps) | Promise.race(ps) |
promises.delay(ms) | sleep(ms) from @rtorcato/js-common/sleep |
boolean.and(a, b) | a && b |
boolean.or(a, b) | a || b |
boolean.not(a) | !a |
boolean.xor(a, b) | a !== b |
strings.padStart(s, n, c) | s.padStart(n, c) |
strings.padEnd(s, n, c) | s.padEnd(n, c) |
strings.replaceString(s, a, b) | s.replaceAll(a, b) |
arrays.first(arr) | arr.at(0) |
arrays.last(arr) | arr.at(-1) |
arrays.flatten(arr) | arr.flat() |
arrays.groupBy(arr, fn) | Object.groupBy(arr, fn) — see the note below |
numbers.isInteger(v) | Number.isInteger(v) |
numbers.isFiniteNumber(v) | Number.isFinite(v) |
numbers.min(ns) | Math.min(...ns) |
numbers.max(ns) | Math.max(...ns) |
objects.deepClone(v) | structuredClone(v) |
json.deepCloneJson(v) | structuredClone(v) — see the note below |
uuid.getUUID() | crypto.randomUUID() |
toBoolean stays in boolean, and sum, average, clamp, mod and
between stay in numbers — those do work a built-in does not.
groupBy is not a drop-in
Object.groupBy differs from the removed helper in two ways that will show up
in a type check rather than at runtime:
// boundary-check: ignore — quotes the pre-4.0 API on purpose
- const byTeam = groupBy(rows, (r) => r.team)
- byTeam.red.length // T[] — always defined
+ const byTeam = Object.groupBy(rows, (r) => r.team)
+ byTeam.red?.length // T[] | undefined — every key is optional
It also returns a null-prototype object, so byTeam.hasOwnProperty(...)
throws. Use Object.hasOwn(byTeam, key) or the in operator. Both differences
are the standard's, not a behaviour change we chose.
deepCloneJson had different semantics
json.deepCloneJson cloned through JSON.parse(JSON.stringify(v)), which
silently drops undefined and functions and turns Dates into strings.
structuredClone keeps Dates, Maps, Sets, typed arrays and cycles, and
throws on functions instead of dropping them.
That is a better result in almost every case, but it is not identical — if
you were relying on the JSON round trip to flatten a value into
JSON-serialisable shape, call JSON.parse(JSON.stringify(v)) explicitly so the
intent is visible.
mimeTypes entries lose compressible
The vendored MIME database no longer carries a compressible flag per entry.
Nothing in the library read it — lookup is built from extensions and
source alone — so it was 913 lines of payload for every consumer of the
mime-types module. MimeValue narrows to { source, extensions } to match.
lookup, types and extensions are unchanged.
If you need the flag, mime-db still
publishes it and is the upstream this database was vendored from.
Two modules removed entirely
./sets and ./interval were nothing but wrappers, so both subpaths are gone —
44 subpaths become 42.
./sets → Set.prototype
Node 22 ships the ES2025 Set methods, so every export had a native equivalent.
| Was | Now |
|---|---|
union(a, b) | a.union(b) |
intersection(a, b) | a.intersection(b) |
difference(a, b) | a.difference(b) |
isSubset(a, b) | a.isSubsetOf(b) |
isSuperset(a, b) | a.isSupersetOf(b) |
setToArray(set) | [...set] |
arrayToSet(arr) | new Set(arr) |
Note the argument order reads differently — isSubset(a, b) asked "is a a
subset of b", and a.isSubsetOf(b) asks the same thing, so these two are a
direct swap. The set-returning methods produce a new Set exactly as before.
./interval → the timer globals
| Was | Now |
|---|---|
runInterval(fn, ms) | setInterval(fn, ms) |
clearIntervalById(id) | clearInterval(id) |
Both wrappers passed their arguments straight through and returned what the global returned, so this is a rename and nothing more.
2.x → 3.x — one home per helper
Thirteen names were exported from two modules each, and five more were the same function under two names. 3.0 gives every helper exactly one home. The losing copy is deleted, not deprecated — you get a build error that names the fix rather than a silent behaviour change. The reasoning is recorded in MODULE-BOUNDARIES.md; module paths are frozen as of that record.
Two modules removed
./formatting and ./math are gone — 46 subpaths become 44.
| Was | Now |
|---|---|
formatting.formatPercent | numbers.formatPercent |
formatting.formatDate | date.formatDate — note: UTC, where the old one was local time |
formatting.formatTime | time.formatTime |
formatting.formatDateTime | datetime.formatDateTimeLocal |
formatting.formatNumber | i18n.formatNumber — takes a locale and Intl.NumberFormat options |
formatting.padZero | strings.padStart(str, n, '0') |
math.add / subtract / multiply / divide | + - * / |
formatting.formatDate formatted in local time and date.formatDate
formats in UTC. Near midnight they disagree on the day, so this one is worth
a second look rather than a blind find-and-replace.
Duplicate names — one owner each
Import from the owner; the other module no longer exports the name.
| Name | Owner | No longer in |
|---|---|---|
roundTo | numbers | currency |
randomString | random | strings |
sanitizeString | security | strings — and renamed, see below |
escapeHtml, unescapeHtml | html | strings |
pluralize | strings | i18n |
formatNumber | i18n | — (formatting removed) |
secondsBetween | time | datetime |
isBoolean | validation | boolean |
getProcessUptime | process | node |
Three of these changed behaviour where the surviving copy was the stricter one:
random.randomString(length, chars?)defaults its charset and draws viarandomInt; thestringscopy required a charset and calledMath.random().time.secondsBetweenacceptsstring | Date; thedatetimecopy took onlyDate.process.getProcessUptime()returnsnumber | undefinedbehind a guard; thenodecopy assumedprocessexists and returnednumber.
html.unescapeHtml also adopted the strings implementation, so it now
decodes ', / and as well.
sanitizeString is now stripScriptish
// boundary-check: ignore — quotes the pre-3.0 name on purpose
- import { sanitizeString } from '@rtorcato/js-common/security'
- sanitizeString(html)
+ import { stripScriptish } from '@rtorcato/js-common/security'
+ stripScriptish(html)
Same function, honest name. It removes <script> blocks and inline on*=
handlers and nothing else — a blocklist over two shapes. It was never a
sanitizer, and the old name invited people to use it as one without reading the
caveat that has been in its docs the whole time.
If you were relying on it to make untrusted HTML safe, renaming the import is
not the fix. Escape with html.escapeHtml, or run
DOMPurify when the markup has to survive.
Three behaviour changes came with the rename, all of them things the old regexes got wrong:
- Unquoted handlers are now removed.
<img src=x onerror=alert(1)>was previously left intact, because the old pattern required matching quotes. So was<img/onerror=alert(1)>, where/separates the attribute instead of a space. </script >now closes a block. The old pattern demanded</script>exactly, so a space defeated it (CodeQLjs/bad-tag-filter).- No stray space is left behind.
<div onclick="x()">yields<div>, not<div >. Update snapshot tests that captured the old output.
Performance also changed by three orders of magnitude on hostile input:
'<script'.repeat(40_000) took 9.8s and now takes ~2ms. The old
implementation backtracked once per <script occurrence
(CodeQL js/polynomial-redos), which was a denial-of-service vector for anyone
passing it attacker-controlled strings.
Renames
| Was | Now |
|---|---|
events.once(target, type) | events.onceEvent(target, type) |
numbers.getRandomInt | random.randomInt |
numbers.getRandomFloat | random.randomFloat |
strings.stripHtml | html.stripHtmlTags |
time.pad2 | kept — but strings.padStart covers the general case |
functions.once keeps its name. It memoises a call, which is a genuinely
different function from awaiting a DOM event, so the event one moved aside.
1.x → 2.x — errors.tryCatch → errors.tryWithFallback
// boundary-check: ignore — quotes the pre-2.0 API on purpose
// 1.x — swallow errors and fall back to a default value
import { tryCatch } from '@rtorcato/js-common/errors'
const data = await tryCatch(() => fetchData(), [])
// 2.x — same function, renamed for clarity
import { tryWithFallback } from '@rtorcato/js-common/errors'
const data = await tryWithFallback(() => fetchData(), [])
The name tryCatch is now reserved for the Result-pattern helper in @rtorcato/js-common/try, which returns { data, error } instead of swallowing:
import { tryCatch } from '@rtorcato/js-common/try'
const { data, error } = await tryCatch(() => fetchData())
if (error) { /* handle */ }
Prefer the Result-style tryCatch for new code; reserve tryWithFallback for cases where the fallback is genuinely safe.