@gmana/utils
v1.6.2
Published
Utility functions for React and TypeScript projects.
Maintainers
Readme
@gmana/utils
Small utility functions for TypeScript, JavaScript, and React projects.
- Tree-shakeable ESM and CommonJS builds with full type declarations.
- Runs in browsers, Node, Bun, and edge runtimes. Browser globals are checked before use.
- Array and object helpers return new values instead of mutating their inputs (
setis the one documented exception). - Helpers that write keys taken from data never let
__proto__,constructor, orprototypereachObject.prototype. - Khmer support: digits, number and currency words, dates, and phone numbers.
The only runtime dependency is tailwind-merge, used by cn. React is an optional peer dependency, needed only for the cssVariables type.
Installation
npm install @gmana/utils
# or
pnpm add @gmana/utils
# or
bun add @gmana/utilsUsage
import { formatNumber, getErrorMessage, pick, safeHref, toggleItem } from "@gmana/utils"
formatNumber(15320) // "15.3K"
pick(["id", "name"], { id: 1, name: "Ada", role: "admin" }) // { id: 1, name: "Ada" }
toggleItem(["a", "b"], "a") // ["b"]
safeHref("javascript:alert(1)") // "about:blank"
getErrorMessage(new Error("Network down")) // "Network down"Contents
| Section | Covers |
| --- | --- |
| Array | Slicing, set operations, grouping, statistics, immutable updates for React state, trees |
| Object | pick/omit, deep get/set/merge/clone/equal, flattening, typed Object.keys |
| String | Case conversion, truncation, slugs, templates, search highlighting, masking, HTML escaping, ids |
| Number | Rounding, compact numbers, currency, percentages, bytes, number words |
| Khmer | Khmer digits, number and currency words, dates, phone numbers |
| Date & Time | Formatting, relative time, date-only strings, date math, comparisons, durations |
| URL | Absolute URLs, query strings, safe hrefs, external-link checks |
| Async & Functions | Sleep, timeout, retry, polling, concurrency limits, request deduplication, debounce, throttle, memoize |
| Errors & Assertions | Invariants, exhaustive switches, normalizing thrown values, result objects, safe JSON |
| Type Guards | is* predicates, environment checks |
| Styling & Color | clsx, cn, CSS variables, hex/RGB, WCAG contrast, generated colors |
| Miscellaneous | JWT payloads and expiry, OS detection, vCard contact cards |
| Migrating from 1.6 | Breaking changes and behavior fixes |
Array
Array helpers accept readonly arrays and never modify them. Helpers that produce an array return a new one.
Access and slicing
first(array) · last(array) · at(array, index)
at accepts negative indices.
first([1, 2, 3]) // 1
last([1, 2, 3]) // 3
at([1, 2, 3], -1) // 3
at([1, 2, 3], 9) // undefinedcastArray<T>(value: T | readonly T[] | null | undefined): T[]
castArray("a") // ["a"]
castArray(["a", "b"]) // ["a", "b"]
castArray(undefined) // []range(start: number, end?: number, step?: number): number[]
With one argument, counts from 0 up to start. Throws a RangeError for a non-finite argument, which would otherwise loop forever.
range(4) // [0, 1, 2, 3]
range(1, 4) // [1, 2, 3]
range(0, 10, 3) // [0, 3, 6, 9]
range(3, 0, -1) // [3, 2, 1]take(array, n?) · takeRight(array, n?) · drop(array, n?) · dropRight(array, n?)
n defaults to 1 and is clamped to [0, array.length].
take([1, 2, 3, 4], 2) // [1, 2]
takeRight([1, 2, 3, 4], 2) // [3, 4]
drop([1, 2, 3, 4], 2) // [3, 4]
dropRight([1, 2, 3, 4], 2) // [1, 2]chunk<T>(array: readonly T[], size: number): T[][]
Throws when size is not a positive integer.
chunk([1, 2, 3, 4, 5], 2) // [[1, 2], [3, 4], [5]]compact<T>(array: readonly (T | Falsy)[]): T[]
Removes false, 0, "", null, undefined, and other falsy values.
compact([1, null, 2, undefined, 3, 0, "", false]) // [1, 2, 3]flatten(array) · flattenDeep(array)
flatten removes one level of nesting. flattenDeep removes all of them.
flatten([1, [2, 3], [4]]) // [1, 2, 3, 4]
flattenDeep([1, [2, [3, [4]]]]) // [1, 2, 3, 4]zip(...arrays) · zipStrict(...arrays) · zipObject(keys, values)
zip pads shorter arrays with undefined, and its element type says so. zipStrict throws on unequal lengths, so its type has no undefined.
zip([1, 2], ["a", "b"]) // [[1, "a"], [2, "b"]]
zip([1, 2], ["a"]) // [[1, "a"], [2, undefined]]
zipStrict([1, 2], ["a", "b"]) // [[1, "a"], [2, "b"]]
zipObject(["a", "b"], [1, 2]) // { a: 1, b: 2 }Set operations
unique(array) · uniqueBy(array, keyFn)
uniqueBy keeps the first item for each key.
unique([1, 2, 2, 3, 3]) // [1, 2, 3]
uniqueBy([{ id: 1, v: "a" }, { id: 1, v: "b" }, { id: 2, v: "c" }], (x) => x.id)
// [{ id: 1, v: "a" }, { id: 2, v: "c" }]difference(a, b) · differenceBy(a, b, keyFn)
Items in a that are not in b.
difference([1, 2, 3], [2]) // [1, 3]
differenceBy([{ id: 1 }, { id: 2 }], [{ id: 1 }], (x) => x.id) // [{ id: 2 }]intersection(a, b) · intersectionBy(a, b, keyFn)
Items in both arrays, taken from a.
intersection([1, 2, 3], [2, 3, 4]) // [2, 3]
intersectionBy([{ id: 1, v: "a" }], [{ id: 1, v: "b" }], (x) => x.id) // [{ id: 1, v: "a" }]symmetricDifference(a, b)
Items in either array, but not both.
symmetricDifference([1, 2, 3], [2, 3, 4]) // [1, 4]Grouping and sorting
groupBy(array, keyFn) · countBy(array, keyFn) · keyBy(array, keyFn)
groupBy collects every item per key, countBy counts them, and keyBy keeps one item per key (the last one wins).
groupBy([{ type: "a" }, { type: "b" }, { type: "a" }], (x) => x.type)
// { a: [{ type: "a" }, { type: "a" }], b: [{ type: "b" }] }
countBy(["a", "b", "a"], (x) => x) // { a: 2, b: 1 }
keyBy([{ id: 1 }, { id: 2 }], (u) => u.id) // { "1": { id: 1 }, "2": { id: 2 } }groupConsecutive(array, keyFn)
Groups runs of adjacent items that share a key, for example messages by the same sender.
groupConsecutive([1, 1, 2, 2, 1], (x) => x) // [[1, 1], [2, 2], [1]]partition(array, predicate)
partition([1, 2, 3, 4], (x) => x % 2 === 0) // [[2, 4], [1, 3]]sortBy(array, ...selectors)
Stable sort by one or more keys. Later selectors break ties.
sortBy(
[
{ last: "B", first: "Z" },
{ last: "A", first: "A" },
{ last: "A", first: "B" },
],
(u) => u.last,
(u) => u.first
)
// [{ last: "A", first: "A" }, { last: "A", first: "B" }, { last: "B", first: "Z" }]Statistics
sum(array) · mean(array) · median(array)
Over arrays of numbers.
sum([1, 2, 3, 4]) // 10
mean([1, 2, 3, 4]) // 2.5
median([1, 2, 3, 100]) // 2.5sumBy(array, selector) · meanBy(array, selector) · medianBy(array, selector?)
median and medianBy are unaffected by outliers, unlike the mean.
sumBy([{ price: 10 }, { price: 20 }, { price: 5 }], (x) => x.price) // 35
meanBy([{ n: 1 }, { n: 2 }, { n: 3 }], (x) => x.n) // 2
medianBy([{ n: 1 }, { n: 2 }, { n: 3 }, { n: 100 }], (x) => x.n) // 2.5minBy(array, selector) · maxBy(array, selector)
Return the item itself, or undefined for an empty array.
minBy([{ n: 1 }, { n: 3 }, { n: 2 }], (x) => x.n) // { n: 1 }
maxBy([{ n: 1 }, { n: 3 }, { n: 2 }], (x) => x.n) // { n: 3 }Random
sample(array, n?) · sampleOne(array) · shuffle(array)
Use Math.random, so they are not suitable for anything security-sensitive.
sample([1, 2, 3, 4, 5], 2) // e.g. [3, 5]
sampleOne(["a", "b", "c"]) // e.g. "b"
shuffle([1, 2, 3, 4]) // e.g. [3, 1, 4, 2]Immutable updates
Each of these returns a new array, so it drops straight into a React state updater: setItems((prev) => removeAt(prev, index)). Negative indices count back from the end, and an out-of-range index returns an unchanged copy instead of throwing.
moveItem(array, fromIndex, toIndex)
For drag-and-drop reordering.
moveItem(["a", "b", "c"], 0, 2) // ["b", "c", "a"]insertAt(array, index, ...items)
Inserts before index, following Array#splice index rules.
insertAt([1, 2, 3], 1, 9) // [1, 9, 2, 3]
insertAt([1, 2, 3], -1, 9) // [1, 2, 9, 3]
insertAt([1, 2, 3], 99, 8, 9) // [1, 2, 3, 8, 9]removeAt(array, index)
removeAt(["a", "b", "c"], 1) // ["a", "c"]
removeAt(["a", "b", "c"], -1) // ["a", "b"]updateAt(array, index, valueOrUpdater)
Pass a function to derive the new item from the old one. To store a function as the item, wrap it: updateAt(handlers, 0, () => newHandler).
updateAt([1, 2, 3], 1, 20) // [1, 20, 3]
updateAt([{ done: false }], -1, (todo) => ({ ...todo, done: true })) // [{ done: true }]toggleItem(array, item, keyFn?)
Removes the item if it is present, otherwise appends it. Useful for multi-selects, expanded rows, and active filters. Pass keyFn when items are objects recreated between renders.
toggleItem(["a", "b"], "c") // ["a", "b", "c"]
toggleItem(["a", "b"], "a") // ["b"]
toggleItem([{ id: 1 }, { id: 2 }], { id: 2 }, (u) => u.id) // [{ id: 1 }]upsertBy(array, item, keyFn)
Replaces the item with the same key in place, or appends it. Use it to merge a fetched or edited record into a list without reordering it.
const users = [{ id: 1, name: "Ada" }, { id: 2, name: "Alan" }]
upsertBy(users, { id: 2, name: "Alan T." }, (u) => u.id) // replaces id 2 in place
upsertBy(users, { id: 3, name: "Grace" }, (u) => u.id) // appends id 3Trees
arrayToTree(items, options?) · treeToArray(tree, childrenKey?)
arrayToTree builds a tree from flat rows that reference their parent by id: menus, comment threads, categories. Items are copied, never mutated, and every input item appears exactly once in the result:
- An item whose parent is missing becomes a root instead of disappearing.
- A parent cycle (A → B → A) is broken, and the first cycle member becomes a root.
Options are idKey (default "id"), parentKey (default "parentId"), and childrenKey (default "children"). treeToArray flattens a tree back into a list, each parent before its children.
const tree = arrayToTree([
{ id: 1, parentId: null, name: "Root" },
{ id: 2, parentId: 1, name: "Child" },
])
// [{ id: 1, parentId: null, name: "Root", children: [{ id: 2, parentId: 1, name: "Child", children: [] }] }]
treeToArray(tree).map((node) => node.name) // ["Root", "Child"]Object
Selecting keys
pick(names, obj) · omit(names, obj)
Both have a curried form for use with map. pick skips __proto__, constructor, and prototype.
pick(["id", "name"], { id: 1, name: "Ada", role: "admin" }) // { id: 1, name: "Ada" }
omit(["password"], { id: 1, password: "x" }) // { id: 1 }
users.map(pick(["id", "name"]))pickBy(obj, predicate) · omitBy(obj, predicate)
pickBy({ a: 1, b: null, c: 3 }, (v) => v !== null) // { a: 1, c: 3 }
omitBy({ a: 1, b: undefined }, (v) => v === undefined) // { a: 1 }compactObject(input, options?)
Removes null, undefined, and "" values, recursing into nested plain objects. Class instances, Dates, and Maps are kept as they are.
interface CompactOptions {
compactArrays?: boolean // also compact inside arrays
removeEmptyArrays?: boolean
isEmpty?: (value: unknown) => boolean // custom emptiness test
}compactObject({ a: 1, b: null, c: "" }) // { a: 1 }
compactObject({ a: [1, null, 2] }, { compactArrays: true }) // { a: [1, 2] }Transforming
mapValues(obj, fn) · mapKeys(obj, fn) · invert(obj)
mapValues({ a: 1, b: 2 }, (n) => n * 10) // { a: 10, b: 20 }
mapKeys({ firstName: "Ada" }, (k) => k.toUpperCase()) // { FIRSTNAME: "Ada" }
invert({ a: "1", b: "2" }) // { "1": "a", "2": "b" }objectKeys(obj) · objectEntries(obj) · objectFromEntries(entries)
Object.keys, Object.entries, and Object.fromEntries, typed with the object's own keys instead of string. TypeScript's built-in types are loose because an object may carry more properties than its type declares, so only use these where the type is exact: a literal, a config, a record you built.
const sizes = { sm: 12, md: 16, lg: 20 }
objectKeys(sizes) // ["sm", "md", "lg"], typed ("sm" | "md" | "lg")[]
objectEntries({ a: 1 }) // [["a", 1]], typed ["a", number][]
objectFromEntries([["a", 1], ["b", 2]] as const) // { a: 1, b: 2 }Deep operations
get(obj, path, defaultValue?) · set(obj, path, value)
Paths use dots and brackets, or an array of segments. get never reads through the prototype chain for an unsafe segment, and set ignores any path containing __proto__, constructor, or prototype. set mutates obj, creating intermediate objects or arrays as needed.
const user = { profile: { tags: ["dev", "lead"] } }
get(user, "profile.tags[0]") // "dev"
get(user, ["profile", "tags", 1]) // "lead"
get(user, "profile.age", 18) // 18
set({}, "items[0].id", 1) // { items: [{ id: 1 }] }deepMerge(target, source) · deepMergeAll(target, ...sources)
Recursively merges plain objects. Arrays and class instances are replaced, not merged element by element. undefined values are skipped so a partial override does not blank out fields it never mentioned; use null to clear one. Inputs are not mutated.
deepMerge({ a: { x: 1, y: 2 } }, { a: { y: 3 } }) // { a: { x: 1, y: 3 } }
deepMerge({ tags: ["a"] }, { tags: ["b"] }) // { tags: ["b"] }
deepMerge({ a: 1 }, { a: undefined }) // { a: 1 }
deepMergeAll(defaults, fileConfig, envConfig)deepEqual(a, b)
Structural comparison of plain objects, arrays, Date, RegExp, Map, and Set. Uses Object.is, so NaN equals NaN. Cyclic structures terminate.
deepEqual({ a: [1, 2] }, { a: [1, 2] }) // true
deepEqual(NaN, NaN) // truedeepClone(value)
Deeply copies objects, arrays, Dates, RegExps, Maps, Sets, and typed arrays, including cyclic references. Every own key is copied as data, __proto__ included, without changing the copy's prototype.
const copy = deepClone(original)flattenObject(obj, separator?) · unflattenObject(obj, separator?)
The separator defaults to "-". Numeric segments become array indices when unflattening, so arrays round-trip as arrays. Entries whose path contains an unsafe segment are skipped.
flattenObject({ a: { b: 1, c: { d: 2 } } }) // { "a-b": 1, "a-c-d": 2 }
flattenObject({ user: { name: "Alice" } }, ".") // { "user.name": "Alice" }
unflattenObject({ "user.name": "Alice", "tags.0": "admin" }, ".") // { user: { name: "Alice" }, tags: ["admin"] }String
Case
capitalize(text, lowercaseRest?)
capitalize("hello world") // "Hello world"
capitalize("hELLO", true) // "Hello"toCase(input, type) and shorthands
Built-in types: lowercase, uppercase, sentence, title, snake, kebab, dot, constant, pascal, camel. Each also has a shorthand: toCamelCase, toPascalCase, toKebabCase, toSnakeCase, toTitleCase, toConstantCase, toDotCase.
toCase("hello world", "pascal") // "HelloWorld"
toCase("helloWorld", "snake") // "hello_world"
toCamelCase("hello-world") // "helloWorld"
toKebabCase("helloWorld") // "hello-world"
toConstantCase("apiBaseUrl") // "API_BASE_URL"extendCases(custom) · listCases()
Registers custom case types, built from the exported steps tokenize, normalizeLower, normalizeUpper, and capitalizeWords, and the join(separator) formatter. Registration is global to the module, so do it once at startup. listCases() returns every registered name.
extendCases({
scream: { steps: [tokenize, normalizeUpper], format: join("!") },
})
toCase("hello world", "scream" as CaseType) // "HELLO!WORLD"Shortening
truncate(text?, options?)
Collapses whitespace, then shortens text to maxLength characters, ellipsis included. It never splits an emoji or other astral character.
interface TruncateOptions {
maxLength?: number
ellipsis?: string
preserveWords?: boolean // cut at a word boundary
returnUndefinedIfEmpty?: boolean
}truncate("Hello world", { maxLength: 7, ellipsis: "..." }) // "Hell..."
truncate("Hello world again", { maxLength: 12, preserveWords: true, ellipsis: "..." }) // "Hello..."
truncate("", { returnUndefinedIfEmpty: true }) // undefinedtruncateMiddle(text, maxLength, options?)
Cuts out the middle and keeps both ends. Use it for file names (keeps the extension), hashes, and wallet addresses. Options: ellipsis (default "…") and endLength.
truncateMiddle("quarterly-report-final-v2.pdf", 20) // "quarterly-…al-v2.pdf"
truncateMiddle("0x71C7656EC7ab88b098defB751B7401B5f6d8976F", 13) // "0x71C7…d8976F"
truncateMiddle("report-2024.pdf", 10, { endLength: 4 }) // "repor….pdf"Slugs and accents
slugify(text, options?)
Options: separator (default "-"), lowercase (default true), and maxLength (never splits a word). Latin accents fold to base letters. Scripts with no ASCII equivalent, such as Khmer, Thai, and CJK, are dropped, so check for an empty result if your content may not be Latin.
slugify("Héllo, World!") // "hello-world"
slugify("Hello World", { separator: "_" }) // "hello_world"
slugify("A very long title", { maxLength: 10 }) // "a-very"removeDiacritics(text)
Strips accents and other combining marks from Latin text. Khmer, Thai, Korean, and CJK text passes through unchanged.
removeDiacritics("Crème brûlée") // "Creme brulee"
removeDiacritics("Nguyễn Văn Ấn") // "Nguyen Van An"Templates and lists
interpolate(template, values, options?)
Fills {placeholder}s, including dotted and indexed paths. Missing values leave the placeholder in place by default, so the gap is visible; pass { missing: "empty" } to remove them. The result is plain text with no HTML escaping.
interpolate("Hi {name}, you have {count} new messages", { name: "Ada", count: 3 }) // "Hi Ada, you have 3 new messages"
interpolate("Signed in as {user.email}", { user: { email: "[email protected]" } }) // "Signed in as [email protected]"
interpolate("Hello {name}", {}) // "Hello {name}"
interpolate("Hello {name}", {}, { missing: "empty" }) // "Hello "formatList(items, options?)
Joins items with the locale's conjunction and serial-comma rules, via Intl.ListFormat. Options: locale (default "en"), type ("conjunction", "disjunction", or "unit"), and style.
formatList(["Ada", "Alan", "Grace"]) // "Ada, Alan, and Grace"
formatList(["red", "blue"], { type: "disjunction" }) // "red or blue"
formatList(["pommes", "poires", "figues"], { locale: "fr" }) // "pommes, poires et figues"pluralize(word, count, pluralForm?)
English rules for regular nouns. Pass the plural form for irregular ones.
pluralize("item", 1) // "item"
pluralize("category", 2) // "categories"
pluralize("person", 2, "people") // "people"formatTitle(base, site, options?)
Builds a page <title> from a base title, a site name, and an optional %s template.
formatTitle("Jobs in Tech", "Acme") // "Jobs in Tech | Acme"
formatTitle("Acme OG Preview", "Acme", { disableSuffix: true }) // "Acme OG Preview"
formatTitle("Jobs", "Acme", { template: "%s - Powered by Acme" }) // "Jobs - Powered by Acme"Search
highlightSegments(text, query, options?)
Splits text into matching and non-matching segments so you can render <mark> without dangerouslySetInnerHTML. Matching ignores case and accents by default, but the segments always hold the original text. query can be one term or an array of terms.
highlightSegments("Café au lait", "cafe")
// [{ text: "Café", match: true }, { text: " au lait", match: false }]highlightSegments(title, query).map((s, i) => (s.match ? <mark key={i}>{s.text}</mark> : s.text))escapeRegExp(text)
Required whenever user input reaches new RegExp(...). Without it, a value containing ( throws and one containing .* matches far more than intended.
new RegExp(escapeRegExp(userQuery), "gi")Names
getInitials(fullName?, fallback?)
Returns up to two initials. fallback defaults to "?".
getInitials("Sun Sreng") // "SS"
getInitials("Sun") // "S"
getInitials(null, "N/A") // "N/A"getNameFromEmail(email)
Guesses a display name from the local part of an email address, dropping digits and +tags.
getNameFromEmail("[email protected]") // "Sun Sreng"
getNameFromEmail("[email protected]") // "John Doe"
getNameFromEmail("invalid-email") // ""Privacy and HTML
mask(text, options?)
Masks sensitive values for display. Emails keep their domain. Options: char, unmaskedStart, unmaskedEnd (default 4), and type ("auto", "email", or "generic").
mask("[email protected]") // "j******[email protected]"
mask("1234567890123456") // "************3456"
mask("SecretValue", { unmaskedStart: 2, unmaskedEnd: 2 }) // "Se*******ue"stripHtml(html)
Removes tags and decodes common entities, for search previews and summaries.
stripHtml("<p>Hello <strong>World</strong>!</p>") // "Hello World!"
stripHtml("<span>Tom & Jerry</span>") // "Tom & Jerry"escapeHtml(text) · unescapeHtml(text)
Escapes the five characters significant in HTML text and quoted attributes. Not a sanitizer: it does not make untrusted HTML safe, and does not cover URL, CSS, or unquoted-attribute contexts. Prefer textContent or DOMPurify.
escapeHtml('<a href="x">') // "<a href="x">"Ids and hashing
uuid() · randomId(length?)
uuid prefers crypto.randomUUID, falls back to crypto.getRandomValues, and only then to Math.random. The last is not cryptographically random and must not be used for tokens or secrets. randomId returns a lowercase alphanumeric id with no modulo bias.
uuid() // e.g. "3b241101-e2bb-4255-8caf-4136c566a962"
randomId(8) // e.g. "k3x9q0ab"hashString(text)
A fast, deterministic 32-bit FNV-1a hash for bucketing, stable colors, and cache keys. It is not a cryptographic hash.
hashString("hello") // 1335831723
hashString("user-42") % 10 // a stable bucket from 0 to 9Number
Math and parsing
clamp(value, min, max) · inRange(value, start, end?) · randomInt(min, max)
inRange excludes end. With two arguments it checks 0 <= value < start. randomInt includes both bounds.
clamp(15, 0, 10) // 10
inRange(3, 5) // true
inRange(5, 1, 5) // false
randomInt(1, 6) // a die rollroundTo(value, decimals?)
Shifts the decimal point with exponent notation instead of multiplying and dividing, which is why roundTo(1.005, 2) returns 1.01 rather than 1.
roundTo(1.005, 2) // 1.01parseNumber(input)
Parses numbers, numeric strings, comma-grouped strings, and Khmer digits.
parseNumber("1,234") // 1234
parseNumber("១២៣") // 123Formatting
formatNumber(value?, decimalPlaces?, threshold?)
Compact suffixes K, M, B, and T. A value that rounds up to the next unit uses that unit. Values below threshold (default 1000) are formatted without a suffix.
formatNumber(1500) // "1.5K"
formatNumber(1234567, 2) // "1.23M"
formatNumber(999_999) // "1M"
formatNumber(-1500) // "-1.5K"formatCompact(value, locale?, decimalPlaces?)
Locale-aware compact formatting via Intl.NumberFormat.
formatCompact(1234567) // "1.2M"
formatCompact(1234567, "en-US", 2) // "1.23M"formatPercent(value, decimalPlaces?, locale?)
Formats a ratio as a percentage.
formatPercent(0.1234) // "12.34%"
formatPercent(0.1234, 1) // "12.3%"formatCurrency(options)
formatCurrency({ amount: 1234.5, currencyCode: "USD" }) // "$1,234.50"
formatCurrency({ amount: 1200000, currencyCode: "USD", compact: true }) // "$1.2M"
formatCurrency({ amount: 1234.5, currencyCode: "EUR", locale: "de-DE" }) // "1.234,50 €"Bytes
formatBytes(bytes, options?)
Options: base (1024 by default, or 1000), precision (default 2), and verbose for full unit names. Whole bytes print without decimals.
formatBytes(512) // "512 B"
formatBytes(1536) // "1.50 KB"
formatBytes(1500, { precision: 0 }) // "1 KB"
formatBytes(1500, { base: 1000 }) // "1.50 KB"toBytes(input, options?) · createByteConverter(defaults?)
Parses a size such as "2.5gb" into bytes. createByteConverter returns a parser with preset options. BYTE_REGEX is the pattern both use.
toBytes("2.5gb") // 2684354560
toBytes("1kb", { base: 1000 }) // 1000
const toDecimalBytes = createByteConverter({ base: 1000 })
toDecimalBytes("1mb") // 1000000Words and ordinals
toOrdinal(n) · numberToOrdinalWord(n)
toOrdinal(22) // "22nd"
toOrdinal(113) // "113th"
numberToOrdinalWord(21) // "Twenty First"numberToWord(n) · wordToNumber(words)
numberToWord handles negatives and decimals, and throws a RangeError above about 1e21. wordToNumber parses the words back.
numberToWord(42) // "Forty Two"
numberToWord(1.05) // "One Point Zero Five"
numberToWord(-0.5) // "Negative Zero Point Five"
wordToNumber("One Hundred Twenty Three") // 123
wordToNumber("Negative One Point Five") // -1.5numberToCurrencyWord(amount, options)
Rounds to the currency's minor unit first, so 1.999 is two dollars, not one dollar and a hundred cents.
numberToCurrencyWord(1.5, { currencyCode: "USD" }) // "One Dollar and Fifty Cents"
numberToCurrencyWord(1000, { currencyCode: "JPY" }) // "One Thousand Yen"
numberToCurrencyWord(1.999, { currencyCode: "USD" }) // "Two Dollars"Khmer
Digits
toAsciiDigits(s) · toKhmerDigits(s) · hasKhmerDigits(s) · isKhmerNumeric(s)
isKhmerNumeric is true when a string holds at least one Khmer digit and nothing else except spaces, ., and ,.
toAsciiDigits("១២៣") // "123"
toKhmerDigits("123") // "១២៣"
hasKhmerDigits("invoice ១២") // true
isKhmerNumeric("១,២៣៤") // true
isKhmerNumeric("123") // falseNumber words
numberToWordKhmer(value, sep?, del?) · numberToCurrencyWordKhmer(amount, majorUnit?, minorUnit?, separator?)
sep goes between groups (default a space) and del introduces the decimals (default " ក្បៀស "). The currency form defaults to riel. Results are cached; clearKhmerWordCache() empties the cache.
numberToWordKhmer(21) // "ម្ភៃមួយ"
numberToWordKhmer(2024) // "ពីរពាន់ ម្ភៃបួន"
numberToWordKhmer(1.5) // "មួយ ក្បៀស ប្រាំ"
numberToCurrencyWordKhmer(1500) // "មួយពាន់ប្រាំរយរៀល"
numberToCurrencyWordKhmer(2.5, "ដុល្លារ", "សេន", " និង ") // "ពីរដុល្លារ និង ហាសិបសេន"Dates
formatDateKhmer(date, options?) · KHMER_MONTHS · KHMER_DAYS
Formats a Gregorian date with Khmer month and day names and, by default, Khmer digits. Options: format ("long", "short", or "numeric"), weekday, and khmerDigits. The Khmer lunar calendar is not covered. KHMER_DAYS starts on Sunday, matching Date#getDay().
formatDateKhmer(new Date(2024, 0, 15)) // "ទី១៥ ខែមករា ឆ្នាំ២០២៤"
formatDateKhmer(new Date(2024, 0, 15), { weekday: true }) // "ថ្ងៃច័ន្ទ ទី១៥ ខែមករា ឆ្នាំ២០២៤"
formatDateKhmer(new Date(2024, 0, 15), { format: "short" }) // "១៥ មករា ២០២៤"
formatDateKhmer(new Date(2024, 0, 15), { format: "numeric", khmerDigits: false }) // "15/01/2024"Phone numbers
normalizeKhmerPhone(input) · isValidKhmerPhone(input) · formatKhmerPhone(input, options?)
Accepts the forms people type: a leading 0, +855, 855, or 00855, with spaces, dashes, dots, or parentheses, and Khmer digits. normalizeKhmerPhone returns E.164, or null. Validation checks the structure (an 8- or 9-digit national number) rather than a list of operator prefixes, which would go stale.
normalizeKhmerPhone("012 345 678") // "+85512345678"
normalizeKhmerPhone("០១២-៣៤៥-៦៧៨") // "+85512345678"
normalizeKhmerPhone("12345") // null
isValidKhmerPhone("+855 97 712 3456") // true
formatKhmerPhone("+85512345678") // "012 345 678"
formatKhmerPhone("012345678", { international: true }) // "+855 12 345 678"Date & Time
Date helpers accept a Date, a date string, or a timestamp (DateInput) and work in local time. They return new Dates and never modify their input. Invalid input produces an Invalid Date, NaN, "", or undefined, depending on the return type, rather than an exception.
Formatting
formatDate(date, options?)
Wraps Intl.DateTimeFormat, returning "" for missing or invalid input instead of "Invalid Date". Accepts locale (default "en-US"), a preset ("short", "medium", "long", or "full"), or any Intl.DateTimeFormatOptions. Formatters are cached.
formatDate(new Date(2024, 0, 15)) // "Jan 15, 2024"
formatDate(new Date(2024, 0, 15), { preset: "full" }) // "Monday, January 15, 2024"
formatDate(new Date(2024, 0, 15), { month: "long", year: "numeric", locale: "fr" }) // "janvier 2024"
formatDate("not a date") // ""formatRelativeTime(date, options?)
Localized relative time via Intl.RelativeTimeFormat. Within 5 seconds it returns the locale's word for the present ("now" in English); set justNow to use your own label.
formatRelativeTime(Date.now() - 60_000) // "1 minute ago"
formatRelativeTime(Date.now() - 86_400_000) // "yesterday"
formatRelativeTime(Date.now() + 3_600_000) // "in 1 hour"
formatRelativeTime(Date.now(), { justNow: "just now" }) // "just now"
formatRelativeTime(Date.now() - 10_000, { locale: "fr" }) // "il y a 10 secondes"toIsoString(value?)
toIsoString(new Date("2024-01-15")) // "2024-01-15T00:00:00.000Z"
toIsoString(1705276800000) // "2024-01-15T00:00:00.000Z"
toIsoString() // undefinedFor Khmer dates, see formatDateKhmer.
Date-only strings
parseDateOnly(text) · toDateOnlyString(date)
new Date("2024-01-15") parses as UTC midnight, which is the previous evening anywhere west of Greenwich, and toISOString().slice(0, 10) makes the opposite mistake to the east. These two work in local time, which is what <input type="date">, birthdays, and due dates need. Impossible dates such as 2024-02-30 are rejected, not rolled over.
parseDateOnly("2024-01-15") // local midnight on Jan 15, 2024
parseDateOnly("2024-02-30") // undefined
toDateOnlyString(new Date(2024, 0, 5, 0, 30)) // "2024-01-05"Arithmetic
addDays(date, n) · addMonths(date, n) · addYears(date, n)
Calendar arithmetic that keeps the time of day across daylight-saving changes. addMonths and addYears clamp to the end of shorter months instead of overflowing into the next one.
addDays(new Date(2024, 0, 31), 1) // Feb 1, 2024
addMonths(new Date(2024, 0, 31), 1) // Feb 29, 2024, not Mar 2
addYears(new Date(2024, 1, 29), 1) // Feb 28, 2025cloneDate(date) · getDaysInMonth(date)
cloneDate(original) // a mutable copy
getDaysInMonth(new Date(2024, 1, 10)) // 29Boundaries
startOfDay · endOfDay · startOfWeek · endOfWeek · startOfMonth · endOfMonth
The end* functions return the last millisecond of the period. Weeks start on Sunday unless you pass { weekStartsOn: 1 } (Monday, as in Cambodia and most of Europe).
startOfDay(new Date(2024, 0, 15, 13, 45)) // Jan 15, 2024 00:00:00.000
startOfWeek(new Date(2024, 0, 17), { weekStartsOn: 1 }) // Mon Jan 15, 2024
endOfMonth(new Date(2024, 1, 15)) // Feb 29, 2024 23:59:59.999Comparison
isSameDay(a, b) · isToday(date) · isYesterday(date) · isTomorrow(date) · isWeekend(date)
isSameDay(new Date(2024, 0, 15, 10), new Date(2024, 0, 15, 23)) // true
isToday(new Date()) // true
isWeekend(new Date(2024, 0, 13)) // truedifferenceInCalendarDays(left, right)
Counts calendar days, ignoring the time of day. Subtracting timestamps directly is off by one whenever the range crosses a daylight-saving change.
differenceInCalendarDays(new Date(2024, 0, 2, 0, 5), new Date(2024, 0, 1, 23, 55)) // 1
differenceInCalendarDays(new Date(2024, 2, 1), new Date(2024, 1, 1)) // 29Durations
formatTime(seconds, options?) · parseTime(timeString, separator?)
formatTime supports "digital", "long", "short", and "compact" formats. parseTime reads SS, MM:SS, or HH:MM:SS and throws when a component is not a whole number.
formatTime(3661, { format: "digital" }) // "1:01:01"
formatTime(3661, { format: "long" }) // "1 hour 1 minute 1 second"
formatTime(3661, { format: "short" }) // "1h 1m 1s"
parseTime("1:01:01") // 3661
parseTime("0:30") // 30URL
Building URLs
absoluteUrl(path?, options?)
Resolves a path against baseUrl, which defaults to NEXT_PUBLIC_APP_URL and then http://localhost:3000. Options also take query and fragment. A query string already in the path is merged, not encoded into the pathname.
absoluteUrl("/users", { query: { page: 2 }, baseUrl: "https://example.com" }) // "https://example.com/users?page=2"
absoluteUrl("/docs", { fragment: "install", baseUrl: "https://example.com" }) // "https://example.com/docs#install"joinPaths(segments, options?)
Joins path segments without doubled or missing slashes. The leading slash follows the first segment unless leadingSlash is set.
joinPaths(["api", "users", "123"]) // "api/users/123"
joinPaths(["/api/", "/users/", null]) // "/api/users"
joinPaths(["api", "users"], { leadingSlash: true }) // "/api/users"Query strings
toQueryString(params, options?) · parseQuery(input)
toQueryString skips null and undefined, where new URLSearchParams(obj) would send the strings "null" and "undefined". Dates become ISO strings. Arrays repeat the key by default; arrayFormat can be "comma" or "brackets".
parseQuery accepts a query with or without ?, a full URL, or a URLSearchParams. Repeated keys and key[] become arrays. Values stay strings.
toQueryString({ q: "red shoes", page: 2, sort: undefined }) // "q=red+shoes&page=2"
toQueryString({ tag: ["a", "b"] }) // "tag=a&tag=b"
toQueryString({ tag: ["a", "b"] }, { arrayFormat: "brackets" }) // "tag%5B%5D=a&tag%5B%5D=b"
parseQuery("?q=shoes&tag=a&tag=b") // { q: "shoes", tag: ["a", "b"] }
parseQuery("https://x.com/s?page=2#top") // { page: "2" }setQueryParams(url, params)
Sets, replaces, or removes parameters (null or undefined removes one) and keeps the rest of the URL, including the #fragment. Relative URLs stay relative, so you can build links from the current route.
setQueryParams("/search?q=shoes&page=3", { page: 1 }) // "/search?q=shoes&page=1"
setQueryParams("/search?q=shoes&page=3", { page: null }) // "/search?q=shoes"
setQueryParams("https://x.com/a#top", { tag: ["a", "b"] }) // "https://x.com/a?tag=a&tag=b#top"Safety and validation
safeHref(url, fallback?, options?)
Use it on any user- or API-supplied URL bound to href or src. React does not block javascript: URLs, so <a href={profile.website}> runs script when the field holds one. Relative URLs pass; absolute URLs must use http:, https:, mailto:, or tel: (configurable with protocols). Browsers ignore tabs, newlines, and control characters inside a URL, so those are removed before the check.
safeHref("https://example.com") // "https://example.com"
safeHref("/settings") // "/settings"
safeHref("javascript:alert(1)") // "about:blank"
safeHref(" java\tscript:alert(1)", "#") // "#"isExternalUrl(url, origin?)
Whether a link leaves the current site, for deciding on target="_blank" or an "opens in a new tab" icon. origin defaults to location.origin in a browser. Relative URLs are internal.
isExternalUrl("/about") // false
isExternalUrl("https://github.com", "https://example.com") // true
isExternalUrl("https://example.com/x", "https://example.com") // falseisHttpUrl(url, options?) · isValidUrl(url)
isHttpUrl requires http: or https: by default; pass protocols to allow others. isValidUrl accepts any scheme.
isHttpUrl("https://example.com") // true
isHttpUrl("ftp://example.com") // false
isHttpUrl("ftp://example.com", { protocols: ["ftp:"] }) // true
isValidUrl("ftp://example.com") // trueAsync & Functions
Promises
sleep(ms, signal?)
Accepts an AbortSignal, so a pending wait can be cancelled instead of leaving a timer that keeps a Node process alive.
await sleep(1000)
await sleep(1000, controller.signal) // rejects on aborttimeout(promiseOrFactory, ms, messageOrOptions?) · TimeoutError
Rejects with a TimeoutError if the work does not settle in time. Pass a factory to make the work itself cancellable: it receives an AbortSignal that aborts on timeout, so a fetch is actually cancelled instead of running on in the background. The third argument is a message or { message, signal }.
await timeout(fetchData(), 5000)
await timeout(() => longTask(), 3000, "Task took too long")
await timeout((signal) => fetch(url, { signal }), 5000, { signal: controller.signal })retry(fn, options?)
Exponential backoff with jitter on by default. Without jitter, every client that fails at the same moment retries at the same moment. Options: attempts (default 3), delay, backoff, maxDelay, jitter, shouldRetry, onRetry, and signal.
await retry(() => fetchJson(url), {
attempts: 5,
shouldRetry: (err) => !(err instanceof HttpError && err.status < 500),
})poll(fn, options?)
Calls fn until until(result) holds (by default, until the result is truthy) and returns that result. Use it to wait on a background job or a payment. Options: interval (default 1000), timeout (rejects with TimeoutError), until, and signal. An error thrown by fn stops polling.
const job = await poll(() => api.getJob(id), {
interval: 2000,
timeout: 60_000,
until: (job) => job.status === "done",
})deferred<T>()
A promise with its resolve and reject functions exposed, like Promise.withResolvers().
const { promise, resolve, reject } = deferred<string>()
worker.on("message", resolve)
worker.on("error", reject)
const result = await promiseConcurrency
createLimiter(concurrency)
Runs at most concurrency tasks at once, in submission order. The limiter exposes activeCount and pendingCount. clearQueue() discards tasks that have not started and rejects their promises with an AbortError.
const limit = createLimiter(3)
const pages = await Promise.all(urls.map((url) => limit(() => fetch(url))))mapConcurrent(items, mapper, options?)
Like Promise.all(items.map(mapper)) with a cap on parallelism (concurrency, default 4). Results keep input order. On the first rejection, no further items start. Accepts a signal.
const users = await mapConcurrent(ids, (id) => fetchUser(id), { concurrency: 5 })dedupeAsync(fn, options?)
Concurrent calls with the same arguments share one in-flight promise. When three components mount together and each asks for the current user, one request goes out. The key is released once the promise settles, so this deduplicates concurrent work and does not cache results. Pass keyFn for arguments JSON.stringify cannot key.
const getUser = dedupeAsync((id: string) => api.get(`/users/${id}`))
const [a, b] = await Promise.all([getUser("1"), getUser("1")]) // one requestFunction wrappers
debounce(fn, wait?, options?) · throttle(fn, wait?, options?)
Framework-free counterparts to the hooks in @gmana/react-hooks. Both accept leading and trailing options and have flush() and cancel(). debounce also has pending(). With leading and trailing both on, a single call runs once, not twice.
const save = debounce((draft: string) => api.save(draft), 1000)
form.addEventListener("submit", () => save.flush())once(fn) · memoize(fn, options?)
memoize keys on JSON.stringify(args) by default. Pass keyFn for richer arguments and maxSize to bound the cache, which is otherwise unbounded. The memoized function exposes its cache.
const init = once(() => expensiveSetup())
const slugOf = memoize((title: string) => slugify(title), { maxSize: 500 })
slugOf.cache.clear()noop() · identity(value)
Default callbacks: noop does nothing and identity returns its argument.
Errors & Assertions
Assertions
invariant(condition, message?) · InvariantError
Asserts that a condition holds and narrows its type from then on. It fails loudly where an assumption breaks, instead of surfacing later as an unexplained undefined. Pass a function to build an expensive message only on failure.
const ctx = useContext(ThemeContext)
invariant(ctx, "useTheme must be used inside <ThemeProvider>")
ctx.mode // no longer possibly undefinedassertNever(value, message?)
Put it in the default of a switch over a union. Adding a member to the union then turns every switch that does not handle it into a compile error, and an unexpected value at runtime throws instead of falling through.
type Shape = { kind: "circle"; r: number } | { kind: "square"; size: number }
function area(shape: Shape) {
switch (shape.kind) {
case "circle":
return Math.PI * shape.r ** 2
case "square":
return shape.size ** 2
default:
return assertNever(shape)
}
}Handling errors
toError(value) · getErrorMessage(error, fallback?)
A catch block receives unknown, because JavaScript can throw anything. toError turns it into an Error, keeping the original as cause. getErrorMessage extracts a readable message, so a UI never shows "[object Object]".
try {
await save()
} catch (error) {
logger.error(toError(error))
toast.error(getErrorMessage(error, "Could not save your changes"))
}attempt(fn) · attemptAsync(promise)
Captures a thrown error as a value, so the failure case has to be handled rather than silently swallowed.
const parsed = attempt(() => JSON.parse(raw))
if (!parsed.ok) return fallback
use(parsed.value)safeJsonParse(text, fallback?) · safeJsonStringify(value, fallback?, replacer?, space?)
safeJsonParse returns fallback (or undefined) on a syntax error instead of throwing. safeJsonStringify handles circular references, BigInts, and symbols.
safeJsonParse('{"a":1}') // { a: 1 }
safeJsonParse("bad json", {}) // {}
safeJsonStringify({ a: 1 }) // '{"a":1}'
safeJsonStringify(100n) // '"100"'Type Guards
Every is* helper is a type guard, so it narrows in the true branch.
function handle(input: unknown) {
if (isString(input)) input.trim() // input: string
if (isArray<number>(input)) input.map((n) => n + 1) // input: number[]
}Values
isString · isNumber · isBoolean · isSymbol · isFunction · isArray · isUndefined
isNumber rejects NaN.
isNumber(42) // true
isNumber(NaN) // false
isUndefined(null) // falseisObject(value) · isPlainObject(value)
isObject is true for any non-null, non-array object, including Dates, Maps, and class instances. isPlainObject is true only for object literals, Object.create(null), and JSON.parse output, which is the check you want before iterating own keys.
isObject(new Date()) // true
isPlainObject({ a: 1 }) // true
isPlainObject(new Date()) // falseisNil(value) · isNotNil(value)
const present = ["a", null, "b"].filter(isNotNil) // string[], unlike filtering with `!isNil(v)`isDate(value) · isRegExp(value) · isPromise(value)
isDate rejects Invalid Date, which instanceof Date accepts. isPromise accepts any thenable.
isDate(new Date("nonsense")) // false
isPromise({ then() {} }) // trueisEmpty(value)
true for null, undefined, blank strings, and empty arrays, objects, Maps, and Sets.
isEmpty(" ") // true
isEmpty(new Map()) // true
isEmpty(0) // falseValidation
isJsonString(str)
isJsonString('{"a":1}') // true
isJsonString("{bad}") // falseisValidComponentName(name, options?)
Validates a kebab-case component name and reports every problem.
isValidComponentName("my-button") // { valid: true, errors: [] }
isValidComponentName("My-Button") // { valid: false, errors: [...] }Environment
isDev() · isProd() · isBrowser() · isNavigator()
Evaluated on each call, and safe in browser bundles that do not define process.
if (isDev()) console.log("debug info")
if (isBrowser()) document.title = "App"Styling & Color
Class names and styles
clsx(...args) · cn(...inputs)
clsx joins class names conditionally, like the clsx package. cn also resolves conflicting Tailwind classes with tailwind-merge, so later classes win.
clsx("foo", { bar: true, baz: false }) // "foo bar"
clsx("a", undefined, null, 0, "b") // "a b"
cn("px-2 py-1", "px-4") // "py-1 px-4"
cn("text-red-500", { "text-blue-500": true }) // "text-blue-500"cssVariables(styles)
Types a React style object so it accepts CSS custom properties.
<article
className="rounded-[var(--radius)] bg-[var(--bg)] p-[var(--padding)]"
style={cssVariables({ "--radius": "20px", "--bg": "#ffffff", "--padding": "24px" })}
/>Color
Colors are hex strings (#rgb, #rgba, #rrggbb, or #rrggbbaa, with the # optional) or { r, g, b, a? } objects. Functions that need a color throw a TypeError for an invalid hex string.
hexToRgb(hex) · rgbToHex(rgb) · withAlpha(color, alpha)
hexToRgb("#ff8800") // { r: 255, g: 136, b: 0 }
hexToRgb("orange") // null
rgbToHex({ r: 255, g: 136, b: 0, a: 0.5 }) // "#ff880080"
withAlpha("#3b82f6", 0.2) // "rgba(59, 130, 246, 0.2)"getLuminance(color) · getContrastRatio(a, b) · getReadableTextColor(background, options?)
WCAG 2.x relative luminance and contrast. AA asks for a ratio of at least 4.5 for body text and 3 for large text. getReadableTextColor picks whichever of light (default "#ffffff") and dark (default "#000000") contrasts more with the background.
getContrastRatio("#000000", "#ffffff") // 21
getContrastRatio("#777777", "#ffffff") >= 4.5 // false
getReadableTextColor("#1e3a8a") // "#ffffff"
getReadableTextColor("#fde68a") // "#000000"stringToColor(text, options?)
Derives a stable color from a string, so a user's avatar keeps its color on every device without storing one. Options: saturation (default 65) and lightness (default 45).
const bg = stringToColor(user.id)
const fg = getReadableTextColor(bg)
// <span style={{ background: bg, color: fg }}>{getInitials(user.name)}</span>Miscellaneous
JWT
These read a token's payload without verifying its signature. Use them for UI decisions such as scheduling a refresh, never for authorization.
decodeJwtPayload(token)
decodeJwtPayload(token)?.sub // "user_123"getTokenExpiry(token) · getTokenIssuedAt(token)
The exp and iat claims, in seconds, or null.
getTokenExpiry(token) // e.g. 1713000000
getTokenIssuedAt(token) // e.g. 1712996400isTokenExpired(token, offsetSeconds?) · getTokenTimeToExpiry(token)
offsetSeconds treats a token as expired that many seconds early, to allow for clock skew. getTokenTimeToExpiry returns the milliseconds left, or 0 once expired.
isTokenExpired(token) // false
isTokenExpired(token, 300) // true if it expires within 5 minutes
getTokenTimeToExpiry(token) // e.g. 54000OS detection
getOs(userAgent) · osMap
Detects the operating system from a user agent. Mobile platforms are checked first, because iOS user agents contain "Mac OS X" and Android ones contain "Linux". osMap holds the OsInfo for every OS type.
getOs(navigator.userAgent) // e.g. { type: "ios", label: "iOS" }vCard
generateVCard(contact) · createVCardBlob(contact)
Generates a vCard 4.0 (RFC 6350) string, or a downloadable .vcf Blob. A contact needs a name, an organization, or a non-empty email. The same functions are available as static methods on VCardGenerator.
const vcard = generateVCard({
firstName: "Sun",
lastName: "Sreng",
email: "[email protected]",
organization: "Gmana",
phone: "+1-555-0100",
})
const blob = createVCardBlob({ firstName: "Sun", lastName: "Sreng", email: "[email protected]" })Migrating from 1.6
Most of this release is additive. These are the deliberate breaking changes.
Type guards narrow now
Every is* helper returns a type predicate rather than boolean. This is
strictly more correct, but code that previously operated on an any inside an
if (isString(x)) branch may now surface real type errors.
isDev and isNavigator are functions
Both were module-level constants, evaluated once at import. isDev additionally
read process.env at module scope, which is a ReferenceError in a browser
bundle that has not shimmed process — it took down the entire import.
// Before
if (isDev) { … }
// After
if (isDev()) { … }isProd() and isBrowser() were added alongside them.
getOs returns different — correct — results
iOS and Android were never detected: "mac" was tested before "ios" (every
iPhone UA reads "like Mac OS X") and "linux" before "android" (every Android
UA begins "Linux; Android"). Code branching on "macos" or "linux" to reach
mobile users was relying on that bug.
numberToWord throws above ~1e21
It previously returned silent nonsense — numberToWord(1e21) was "One" — because
the scale table ran out and the missing word became an empty string. It now throws
a RangeError.
isHttpUrl checks the protocol of URL instances too
The protocol allow-list was previously applied only when the argument was a
string, so isHttpUrl(new URL("javascript:alert(1)")) returned true. It also takes
an options object now: isHttpUrl(url, { protocols: ["ftp:"] }).
dropRight / takeRight clamp negative counts
A negative n used to invert the operation — dropRight(arr, -2) took two
elements. Both now clamp to [0, array.length], and n defaults to 1.
chunk rejects fractional sizes
chunk([1,2,3,4,5], 2.5) returned the ragged [[1,2],[3,4,5]]. It now throws.
parseTime rejects malformed components
Each component must be a whole number. parseTime used to read leading digits and ignore the rest, so "5abc:1x" returned 301 and "1.5:00" returned 60. Both now throw.
generateVCard requires a non-empty email
A contact whose only emails were empty strings produced a card with no FN line, which is invalid. It now fails validation like a contact with no name, organization, or email.
Output fixes
These calls now return the corrected values. Update any code or snapshot tests that expected the old output.
| Call | 1.6 | Now |
| --- | --- | --- |
| clsx(0, "a"), cn(0, "a") | "0 a" | "a", matching the clsx package |
| formatNumber(999_999) | "1000K" | "1M" |
| formatNumber(0.5, 1, 0) | "0" | "0.5" |
| formatBytes(512) | "512.00 B" | "512 B" |
| numberToWord(-0.5) | "Zero Point Five" | "Negative Zero Point Five" |
| numberToWord(1e-7) | "" | "Zero Point Zero Zero Zero Zero Zero Zero One" |
| numberToWordKhmer(1e-7) | "សូន្យ ក្បៀស " | The digits, in words |
| truncate on emoji | Could leave half an emoji | Never splits a character |
| compactObject with a class instance | Converted it to a plain object | Keeps the instance unchanged |
| pick(["__proto__"], parsedJson) | Replaced the result's prototype | Skips the key |
Naming improvements & unifications
To improve clarity, ecosystem alignment, and consistency across the API, several functions were renamed:
- Initials:
getInitialLetter→getInitials(returns multi-character initials like"JD"). - Object vs Array flattening:
flatten→flattenObject(for nested object flattening).unflatten→unflattenObject(for nested object unflattening).flattenArray→flatten(standard 1-level array flattening).flattenDeepArray→flattenDeep(standard deep array flattening).
- Type Guards:
isUndef→isUndefined(consistent withisBoolean,isNumber,isString,isArray, etc.).isValidJsonString→isJsonString.
- URL & Web:
isUrl→isHttpUrl(clarifies HTTP/HTTPS protocol validation vs arbitrary URI validation inisValidUrl).
- Formatting & Text:
makeTitle→formatTitle.truncateText→truncate(andTruncateOptions).- Added standalone case helpers:
toCamelCase,toPascalCase,toKebabCase,toSnakeCase,toTitleCase,toConstantCase,toDotCase.
- Khmer & Digits:
toASCII→toAsciiDigits.toKhmer→toKhmerDigits.wordsToNumber→wordToNumber.numberToWordKm→numberToWordKhmer.numberToCurrencyWordKm→numberToCurrencyWordKhmer.clearKmCache→clearKhmerWordCache.
- Dates & OS:
toIso→toIsoString.getOS→getOs(andOsInfo).
- JWT & vCard:
getTokenExpClaim→getTokenExpiry(anddecodeJwtPayload,getTokenIssuedAt,getTokenTimeToExpiry).- Exported functional helpers
generateVCardandcreateVCardBlob, plus clean interfacesVCardAddress,VCardSocialMedia,VCardMessagingPlatforms.
Smaller signature changes
zip's element type includesundefined, matching the padding it always did.getOsreturns the exportedOsInfointerface.sampletakes areadonly T[].formatTitle's third parameter is now optional.CompactOptionsis exported; four unused JSON types were removed.
License
MIT
