npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@gmana/utils

v1.6.2

Published

Utility functions for React and TypeScript projects.

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 (set is the one documented exception).
  • Helpers that write keys taken from data never let __proto__, constructor, or prototype reach Object.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/utils

Usage

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) // undefined

castArray<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.5

sumBy(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.5

minBy(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 3

Trees

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) // true

deepClone(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 }) // undefined

truncateMiddle(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 &amp; 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">') // "&lt;a href=&quot;x&quot;&gt;"

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 9

Number

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 roll

roundTo(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.01

parseNumber(input)

Parses numbers, numeric strings, comma-grouped strings, and Khmer digits.

parseNumber("1,234") // 1234
parseNumber("១២៣") // 123

Formatting

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") // 1000000

Words 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.5

numberToCurrencyWord(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") // false

Number 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() // undefined

For 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, 2025

cloneDate(date) · getDaysInMonth(date)

cloneDate(original) // a mutable copy
getDaysInMonth(new Date(2024, 1, 10)) // 29

Boundaries

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

Comparison

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)) // true

differenceInCalendarDays(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)) // 29

Durations

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") // 30

URL

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") // false

isHttpUrl(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") // true

Async & 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 abort

timeout(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 promise

Concurrency

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 request

Function 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 undefined

assertNever(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) // false

isObject(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()) // false

isNil(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() {} }) // true

isEmpty(value)

true for null, undefined, blank strings, and empty arrays, objects, Maps, and Sets.

isEmpty("   ") // true
isEmpty(new Map()) // true
isEmpty(0) // false

Validation

isJsonString(str)

isJsonString('{"a":1}') // true
isJsonString("{bad}") // false

isValidComponentName(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. 1712996400

isTokenExpired(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. 54000

OS 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 with isBoolean, isNumber, isString, isArray, etc.).
    • isValidJsonString → isJsonString.
  • URL & Web:
    • isUrl → isHttpUrl (clarifies HTTP/HTTPS protocol validation vs arbitrary URI validation in isValidUrl).
  • Formatting & Text:
    • makeTitle → formatTitle.
    • truncateText → truncate (and TruncateOptions).
    • 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 (and OsInfo).
  • JWT & vCard:
    • getTokenExpClaim → getTokenExpiry (and decodeJwtPayload, getTokenIssuedAt, getTokenTimeToExpiry).
    • Exported functional helpers generateVCard and createVCardBlob, plus clean interfaces VCardAddress, VCardSocialMedia, VCardMessagingPlatforms.

Smaller signature changes

  • zip's element type includes undefined, matching the padding it always did.
  • getOs returns the exported OsInfo interface.
  • sample takes a readonly T[].
  • formatTitle's third parameter is now optional.
  • CompactOptions is exported; four unused JSON types were removed.

License

MIT