Skip to main content

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.

WasNow
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 importAdd
@rtorcato/js-common/envpnpm add zod
@rtorcato/js-common/loggerpnpm add pino
@rtorcato/js-common/uuidpnpm 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.

WasNow
validation.isEmailisValidEmail from @rtorcato/js-common/emails
validation.isUrlisValidUrl from @rtorcato/js-common/url
os.getOsPlatformgetProcessPlatform 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.

CallWasNow
addMonths('2026-01-31', 1)2026-03-032026-02-28
addMonths('2024-01-31', 1)2024-03-022024-02-29
addMonths('2026-03-31', -1)2026-03-032026-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.

WasNow
hashString(s) → stringawait hashString(s) → Promise<string>
hmacHash(s, key) → stringawait 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 nameOnly '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​

WasNow
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.

WasNow
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​

WasNow
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.

WasNow
formatting.formatPercentnumbers.formatPercent
formatting.formatDatedate.formatDate — note: UTC, where the old one was local time
formatting.formatTimetime.formatTime
formatting.formatDateTimedatetime.formatDateTimeLocal
formatting.formatNumberi18n.formatNumber — takes a locale and Intl.NumberFormat options
formatting.padZerostrings.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.

NameOwnerNo longer in
roundTonumberscurrency
randomStringrandomstrings
sanitizeStringsecuritystrings — and renamed, see below
escapeHtml, unescapeHtmlhtmlstrings
pluralizestringsi18n
formatNumberi18n— (formatting removed)
secondsBetweentimedatetime
isBooleanvalidationboolean
getProcessUptimeprocessnode

Three of these changed behaviour where the surviving copy was the stricter one:

  • random.randomString(length, chars?) defaults its charset and draws via randomInt; the strings copy required a charset and called Math.random().
  • time.secondsBetween accepts string | Date; the datetime copy took only Date.
  • process.getProcessUptime() returns number | undefined behind a guard; the node copy assumed process exists and returned number.

html.unescapeHtml also adopted the strings implementation, so it now decodes &#x27;, &#x2F; and &nbsp; 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 (CodeQL js/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​

WasNow
events.once(target, type)events.onceEvent(target, type)
numbers.getRandomIntrandom.randomInt
numbers.getRandomFloatrandom.randomFloat
strings.stripHtmlhtml.stripHtmlTags
time.pad2kept — 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.