@forthtilliath/ts-kit
v0.11.0
Published
Framework-agnostic TypeScript utility functions — arrays, objects, strings, async control-flow, dates, numbers, browser file downloads, changelog/version parsing, and a couple of `Array` extensions with better type-narrowing.
Readme
@forthtilliath/ts-kit
Framework-agnostic TypeScript utility functions — arrays, objects, strings,
async control-flow, dates, numbers, browser file downloads, changelog/version
parsing, and a couple of Array extensions with better type-narrowing.
Install
Within the workspace:
{
"dependencies": {
"@forthtilliath/ts-kit": "workspace:*"
}
}Usage
Import everything you need from the package root — a single barrel re-exports every category, so there's no need to remember (or type out) one import line per module:
import { chunk, clamp, debounce, pick, slugify } from "@forthtilliath/ts-kit";Subpath imports still work if you'd rather import one module at a time (e.g. to keep a bundle's included code obvious at a glance):
import { chunk } from "@forthtilliath/ts-kit/array/chunk";Browser-only functions
Everything runs in any JavaScript runtime (browser, Node, React Native,
Angular SSR…) except these, which need a DOM (document, Image,
URL.createObjectURL) and throw elsewhere — only call them client-side:
csv/downloadCsvfiles/downloadText,files/downloadTextBlobimage/compressImage
Importing them is harmless anywhere: the DOM is only touched when called.
array
chunk(array, size)— splits an array into chunks of a given size.createColumnStorage(defaults)—{ serialize, deserialize }pair to persist a user-customized column list (order + visibility, e.g. inlocalStorage). Onlykey/visibleare stored; on read, unknown keys are dropped, new columns appended, and invalid input falls back todefaults.flattenDeep(array)— recursively flattens nested arrays, typed as the innermost element type (number[][][]→number[]).getMostRecentIds(rows, limit?)— most recently occurring distinct ids ({ id, occurredAt }[]), most recent first, deduplicated, limited (default 5).nextInCycle(ids, currentId)— next id in a short list (e.g. cycling through recents on tap) — wraps to the first ifcurrentIdis at the end or no longer in the list.rankByNameMatch(items, query, getName)— ranksitemsby relevance toquery: earlier match position first, then shorter name — for a search-as-you-type list.sortUpcomingFirst(items, getDates, { now? })— upcoming items first (nearest first), then past ones (most recent first), undated last — for a list of events. An item may carry several dates: it stays upcoming while one is ahead (ranked by its next date), then is ranked by its latest date.swapItems(items, i, j)— copy ofitemswith the elements atiandjswapped (e.g. moving an item up/down) — a plain copy if either position is out of bounds.
async
sleep(ms, signal?)— resolves after a delay; rejects early ifsignalaborts.retry(fn, options)— retries an async operation with exponential backoff. Options:attempts(3),delayMs(200),backoffFactor(2),maxDelayMs,shouldRetry(error, attempt)(returnfalseto rethrow at once, e.g. on a 4xx),onRetry(error, attempt)(called before each actual retry),signal.debounce(fn, wait)— delaysfnuntil calls stop forwaitms; exposescancel()/flush().throttle(fn, wait)— runsfnat most once perwaitms (leading + trailing edge); exposescancel().withTimeout(operation, ms, message?)— rejects with aTimeoutErrorifoperationdoesn't settle in time. Pass a function(signal) => promiseinstead of a promise to also cancel the operation on timeout (e.g.withTimeout((signal) => fetch(url, { signal }), 5000)).memoize— moved tofunction; still importable fromasync/memoize(deprecated).
classes
FArray— anArraysubclass that fixes a handful of spots where the nativeArraytypings are wrong or overly restrictive:includes()narrows the type of the searched value when it's found (the nativeArray.prototype.includesonly narrows the array's element type).indexOf()/lastIndexOf()accept a search value outside of the array's element type instead of rejecting it at compile-time (same root cause asincludes, fixed the same way).filter(Boolean)stripsnull/undefinedfrom the result's type (the native typings don't recognizeBooleanas a type guard, so the result keeps the nullish types even though they're removed at runtime).FArray.from(...)/FArray.of(...)are typed to return anFArrayinstead of a plain array (the native static typings return a plain array even though these methods already construct an instance of the calling class at runtime).toArray()converts anFArrayback into a plainArrayinstance — rarely needed sinceFArrayis already assignable toT[]anywhere a plain array is expected, but useful when the runtime type matters too.
csv
parseCsvLine(line, delimiter?)— splits one CSV line into trimmed cells; quoted cells may contain the delimiter,""stands for a literal quote. Delimiter defaults to",".toCsv(rows, delimiter?)— serializes whole rows to CSV, every cell quoted and escaped, rows joined with\n. Delimiter defaults to",".escapeCsvField(value, delimiter?)— escapes a single field (RFC 4180), quoting it only if it contains a", the delimiter or a line break — to build rows cell by cell. Delimiter defaults to";"(French-locale Excel).formatCsvNumber(value, decimals?)— formats a number with a comma decimal separator (French-locale spreadsheets) instead of JS's dot.downloadCsv(filename, content)— browser download astext/csv, with a UTF-8 BOM so Excel reads accented characters.
escapeCsvField and formatCsvNumber used to live in string/ and
number/; those deep imports still work (deprecated).
date
startOfDay(date?)— returns a newDateat local midnight.isSameDay(a, b)— checks whether two dates fall on the same local day.formatRelativeTime(date, baseDate?, locale?)— human-readable relative time ("3 hours ago"), backed byIntl.RelativeTimeFormat.getPeriodStartMs(period, now?)— start timestamp (ms) for a"today" | "7d" | "30d" | "all"period filter, ornullfor"all"."today"is the current calendar day, not a rolling 24h window.dateInTimeZone(date, timeZone)— the calendar date of an instant in an IANA time zone,{ year, month, day, iso }(monthfrom 1 to 12), whatever the machine's own time zone.todayInTimeZone(timeZone, now?)— today's date in that time zone: on a server running in UTC,new Date()is still "yesterday" in Paris just after midnight.daysUntil(date, timeZone, now?)— calendar days between today and a date, both taken in that time zone (0today,1tomorrow,-1yesterday); unaffected by the time of day or daylight saving time.
files
downloadText(filename, text)— triggers a browser download of a text file via adata:URI.downloadTextBlob(filename, text)— same, but via aBlob+ object URL (better for larger content). The object URL is revoked 40 s later, not right after the click (which can cancel the download in Firefox/Safari).
function
memoize(fn, getKeyOrOptions?)— cachesfn's results per argument tuple (JSON-stringified by default); exposes the underlyingcachemap. Pass a key function, or{ getKey, maxSize }to evict the least recently used result beyondmaxSize. A returned promise that rejects is evicted, so the next call retries instead of failing forever.sanitizeFileName(name)— replaces characters forbidden in file names (\ / : * ? " < > |) with hyphens, collapses whitespace and trims.
id
randomId()—crypto.randomUUID()with fallbacks for older/insecure contexts.
image
compressImage(file, { maxWidth?, quality? })— browser-side compression via the Canvas API: scales down tomaxWidth(default 2400px, aspect ratio kept) and converts to WebP (default quality 0.82). Non-image files are returned unchanged.
markdown
parseChangelogNotes(notes)— parses a small subset of Markdown (### heading,- item,**bold**) commonly found in GitHub release notes into a list of typed blocks (heading/item/text, each withbold-aware segments), ready for a UI layer to render without a full Markdown dependency.
number
sum(numbers)— sum of an array of numbers (0if empty).avg(numbers)— average of an array of numbers;NaNif empty (no meaningful average of nothing, like lodash'smean).clamp(value, min, max)— clamps a number between two bounds.round(value, decimals?)— rounds to a given number of decimal places.formatBytes(bytes, decimals?)— human-readable byte size ("1.5 MB").formatDuration(ms)— human-readable duration ("1d 1h 1m 1s").padNumber(n, total, minWidth?)— zero-padsnto the width oftotal(at leastminWidth, default 2) so numbered file names sort correctly.
sum and avg used to live in maths/; those deep imports still work
(deprecated).
object
pick(obj, keys)/omit(obj, keys)— keep or drop a set of keys.groupBy(array, getKey)— groups elements by a computed key.uniqueBy(array, getKey)— deduplicates elements by a computed key.deepClone(value)— deep clone (viastructuredClone, with a JSON fallback).deepEqual(a, b)— recursive structural equality check.mapValues(obj, fn)— maps every value of an object throughfn.
string
capitalize(str)— uppercases the first letter, lowercases the rest.truncate(str, maxLength, suffix?)— truncates with an ellipsis (or custom suffix).slugify(str)— URL-friendly slug (accents stripped, lower-cased, hyphenated).pluralize(count, singular, plural? | { plural?, locale? })— picks the singular or the plural form. Which counts take the singular follows the locale's rules (Intl.PluralRules,"en"by default): only ±1 in English, 0 and 1.5 too in French. Without an explicit plural form, a naive suffix: Englishs/es/ies, Frenchs(nothing afters/x/z),selsewhere.createPluralize(locale)—pluralizebound to a locale, declared once per app:const plural = createPluralize("fr"); plural(n, "est", "sont").normalizeForSearch(text)— lowercases, trims, strips accents, and expands œ/æ ligatures (whichnormalize("NFD")alone doesn't decompose) — for accent/case-insensitive search matching.escapeHtml(text)— HTML entity escaping (&,<,>,",') for inserting user text into an HTML template, in text content or in an attribute quoted with"or'.
version
compareVersions(a, b)— compares two"x.y.z"version strings, segment by segment;-1/0/1.
Scripts
pnpm run build # tsc -> dist/ (excludes *.test.ts)
pnpm run dev # tsc --watch, same exclusion
pnpm run check-types # tsc --noEmit, includes test files
pnpm run lint # eslint
pnpm run test # vitest runType-checking and building use different tsconfigs on purpose:
tsconfig.json includes *.test.ts (so lint/check-types see them),
tsconfig.build.json excludes them (so they never end up in dist/).
