A lightweight, dependency-light set of TypeScript utilities shared across projects. Tree-shakeable, ESM-only, and fully typed.
bun add @dirstack/utils
# or: npm install @dirstack/utilsRequires Node.js 22.12 or later. The package ships ES modules only, and CommonJS code can require() it on these versions.
Everything is exported from the package root:
import { chunk, clamp, formatBytes, slugify, tryCatch } from "@dirstack/utils"
chunk([1, 2, 3, 4, 5], 2) // [[1, 2], [3, 4], [5]]
clamp(12, 0, 10) // 10
formatBytes(1536, 1) // "1.5 KB"
slugify("Hello World") // "hello-world"
const { data, error } = await tryCatch(fetch("/api"))Each module is also available on its own path, such as @dirstack/utils/array or @dirstack/utils/time. Bundlers remove unused code from the root import either way, because the build keeps one file per module.
| Module | Highlights |
|---|---|
array |
uniq, uniqBy, chunk, groupBy, keyBy, countBy, compact, sortBy, sum, sumBy, range |
batch |
processBatch, processBatchSettled (ordered, concurrency-limited, with onProgress) |
errors |
getErrorMessage, toError |
files |
formatBytes, isMimeTypeMatch, toBase64, toDataUrl |
format |
formatNumber, formatCurrency, formatIntervalAmount, currencyExponent, minorUnits |
helpers |
sleep, isTruthy, tryCatch, retry, withTimeout |
http |
isValidUrl, addProtocol, removeProtocol, getDomain, isExternalUrl, joinUrlPaths, setQueryParams, removeQueryParams, isValidImageSrc, isHostname, normalizeHostname, stripWildcard, isWithinDomain, isIp, isIpInRange, isCloudflareIp, getClientIp, CLOUDFLARE_IP_RANGES |
numbers |
clamp, preciseRound |
objects |
pick, omit, isEmptyObject, isKeyInObject |
parsers |
serialize |
random |
getRandomString, getRandomDigits, getRandomNumber, getRandomElement |
string |
ucFirst, lcFirst, truncate, slugify, getInitials, joinAsSentence |
time |
formatDate, formatTime, formatDateTime, formatDateRange, getReadTime, dayKey, shiftDayKey, dayIn, fromUnix, toUnix, SECOND_MS, MINUTE_MS, HOUR_MS, DAY_MS, MINUTE_SECONDS, HOUR_SECONDS, DAY_SECONDS |
ui |
getBalancedColumns, isLightColor, getCurrentPage, getPagination, getPageLink |
| types | WithOptional, WithRequired, plus the types in helper signatures, such as Result (from tryCatch), Timestamp and ProcessBatchOptions |
The date helpers accept any Intl.DateTimeFormat option plus a locale (defaults to "en-US"). Without a timeZone, they format in the time zone of the machine that runs them. On server-rendered pages, the server (often UTC) and the browser (the visitor's zone) can then produce different text, which causes React hydration errors. Pass a fixed timeZone to get the same output in both places:
import { formatDate, formatDateRange } from "@dirstack/utils"
formatDate("2026-10-05T23:30:00Z", { dateStyle: "long", timeZone: "UTC", locale: "en-GB" }) // "5 October 2026"
formatDate("2026-10-05", { day: "2-digit", month: "short", year: "numeric", timeZone: "UTC" }) // "Oct 05, 2026"
formatDateRange("2026-10-05", "2026-10-09", { timeZone: "UTC" }) // "Oct 5 – 9, 2026"Date-only strings such as "2026-10-05" parse as UTC midnight, so format them with timeZone: "UTC" to keep the same calendar day everywhere.
getClientIp reads the visitor's address from request headers set by a reverse proxy such as Traefik. The first x-forwarded-for entry comes from the client and can be forged, so it returns the last entry, which the proxy appends as the TCP peer. It falls back to x-real-ip, and returns undefined when no header holds a valid IP address:
import { getClientIp } from "@dirstack/utils"
getClientIp(request.headers) ?? "127.0.0.1"Behind proxied Cloudflare records, the peer is a Cloudflare server. Pass trust: "cloudflare" to return cf-connecting-ip instead. The header is read only when the peer is in CLOUDFLARE_IP_RANGES, because a request sent straight to the origin can set it to anything:
getClientIp(request.headers, { trust: "cloudflare" })The IP helpers parse IPv4 and IPv6 in plain TypeScript, without node:net, so they also run in browsers and edge runtimes.
v3 leaves one way to call each helper. Each removed alias or call form below has a direct replacement.
The CommonJS/UMD build (dist/index.umd.cjs) is gone. The package is ESM-only and needs Node.js 22.12 or later.
// Before: CommonJS on any Node.js version
const { slugify } = require("@dirstack/utils")
// After: an import, or require() on Node.js 22.12+
import { slugify } from "@dirstack/utils"// Before // After
splitArrayIntoChunks(items, 10) chunk(items, 10)
keepNumberInRange(value, 0, 100) clamp(value, 0, 100)
pickFromObject(user, ["id", "name"]) pick(user, ["id", "name"])// Before // After
formatDate(date, "long", "en-GB") formatDate(date, { dateStyle: "long", locale: "en-GB" })
formatTime(date, "short", "pl") formatTime(date, { timeStyle: "short", locale: "pl" })
formatDateTime(date, "long", "short", "de") formatDateTime(date, { dateStyle: "long", timeStyle: "short", locale: "de" })
formatDateRange(start, end, "short", "es") formatDateRange(start, end, { dateStyle: "short", locale: "es" })// Before // After
sortBy(posts, post => post.date, "desc") sortBy(posts, post => post.date, { order: "desc" })No project used these, so v3 drops them. Each has a short native or library replacement.
| Removed | Use instead |
|---|---|
getElementPosition |
element.getBoundingClientRect() or element.scrollIntoView() |
setInputValue |
Copy the five lines into the project that needs them |
subscribe, unsubscribe, publish |
document.addEventListener, removeEventListener, dispatchEvent(new CustomEvent(name, { detail })) |
publishEscape |
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })) |
isErrorWithMessage, toErrorWithMessage, ErrorWithMessage |
getErrorMessage(error) or toError(error) |
formatMimeType |
mimeType.split("/")[1]?.toUpperCase() |
formatToDecimals |
formatNumber(value, { maximumFractionDigits: 2, useGrouping: false }) |
debounce, throttle |
useDebouncedCallback and useThrottledCallback from @mantine/hooks |
getBaseUrl |
new URL(url).origin |
getQueryParams |
Object.fromEntries(new URL(url).searchParams) |
checkUrlAvailability |
fetch(url, { method: "HEAD", signal: AbortSignal.timeout(5000) }) |
normalizeUrl, isLocalhostUrl |
Still used inside the package, no longer exported |
parseNumericValue |
Number.parseFloat(value) with a Number.isNaN check, or z.coerce.number() |
sortObject |
Object.fromEntries(Object.entries(source).sort(([a], [b]) => a.localeCompare(b))) |
sortObjectKeys |
A comparator in the project that needs it |
nullsToUndefined, ReplaceNullWithUndefined |
Map the fields that need it |
maybeParseJson, maybeStringifyJson, deserialize |
JSON.parse and JSON.stringify |
getRandomColor |
Math.floor(Math.random() * 0x1000000).toString(16).padStart(6, "0") |
getRandomProperty |
getRandomElement(Object.values(source)) |
isCuid |
isCuid from @paralleldrive/cuid2 |
formatDateOrTime |
formatDate, formatTime or formatDateTime |
getShortcutLabel |
A <Kbd> component that knows the platform's modifier key |
DeepIdx, DeepIndex, ValidatePath |
Path and PathValue from react-hook-form, or Get and Paths from type-fest |
NestedPartial, NestedRequired |
PartialDeep and RequiredDeep from type-fest |
Like Promise.allSettled, each result now says whether its item succeeded and carries the item, instead of mixing values and Error objects in one array. Options, including onError, are unchanged.
// Before
const results = await processBatchWithErrorHandling(items, processor, { batchSize: 10 })
const failed = results.filter(result => result instanceof Error)
// After
const results = await processBatchSettled(items, processor, { batchSize: 10 })
const failed = results.filter(result => result.status === "rejected") // { item, error }toBase64 used to return a data URL (data:image/png;base64,…), so most callers stripped the prefix. It now returns the Base64 string alone, and toDataUrl returns the full data URL. Both accept any Blob and work in Node.js too.
// Before // After
(await toBase64(file)).replace(/^data:[^;]+;base64,/, "") await toBase64(file)
await toBase64(file) // when the data URL was wanted await toDataUrl(file)// Before // After
slugify(title, true) slugify(title, { decamelize: true })Every caller passed plain text, so truncate drops the HTML stripping (which also deleted any text after a <). It cuts between words, never splits an emoji, and keeps the result within length including the ….
// Before // After
getExcerpt(description, 125) truncate(description, 125)Three details changed: the ellipsis is … instead of ..., it counts toward length, and empty input returns "" instead of null. stripHtml and convertNewlines are removed too.
getPageParams read the page from a URL and returned the other query parameters under an unchecked type. getPagination only does the math: parse the page with your router or nuqs first.
// Before // After
getPageParams<Filters>(url, 20) getPagination({ page, perPage: 20 }) // { skip, take }// Before // After
joinAsSentence(names) joinAsSentence(names, { limit: 3, locale: "en-GB" })
joinAsSentence(names, 5) joinAsSentence(names, { limit: 5, locale: "en-GB" })
joinAsSentence(names, undefined, t("and")) joinAsSentence(names, { locale })
joinAsSentence(names, undefined, "or") joinAsSentence(names, { type: "disjunction", locale: "en-GB" })joinAsSentence now formats with Intl.ListFormat, which changes its output in three ways:
- It has no default limit. A
limitcollapses the rest into one trailing item ("a, b, and 2 more") instead of dropping it, and never hides a single item. - The default
en-USlocale adds a serial comma: "a, b, and c". Passlocale: "en-GB"for "a, b and c", as in v2. - The locale sets the conjunction and punctuation. Pass the active locale instead of a translated "and".
bun install # install dependencies
bun test # run the test suite on Bun
bun run test:node # run the same suite on Node.js with Vitest
bun run typecheck
bun run lint
bun run build # build with tsdown, then check the package with publint and attwMIT © Piotr Kulpinski