modern-fns
v1.0.1
Published
Modern JavaScript & TypeScript utility library — 145 modular, immutable, tree-shakeable, dependency-free utilities. A lightweight alternative to Lodash.
Maintainers
Readme
modern-fns
Modern utility functions for JavaScript and TypeScript — modular, immutable, tree-shakeable and dependency-free.
145 focused utilities for the problems frontend and backend developers solve by hand over and
over: reading a nested path safely, diffing two objects for a PATCH body, editing a query
string without losing the hash, parsing "₹1,299.50" into a number, formatting 1500000 as
"1.5M".
npm install modern-fnsimport { groupBy, set, diff, slugify, currency } from 'modern-fns';
groupBy(orders, 'status'); // { paid: [...], pending: [...] }
set(state, 'user.address.city', 'Kochi'); // immutable — `state` untouched
diff(pristine, form); // [{ path: 'name', type: 'changed', ... }]
slugify('Hello Vue World!'); // 'hello-vue-world'
currency(1299.5, 'INR', 'en-IN'); // '₹1,299.50'| | |
| ----------------------------- | ----------------------------------------------------------------------------------------- |
| Zero runtime dependencies | Nothing but the platform. No Lodash, no qs, no deepmerge. |
| Tree-shakeable | One function per file. import { chunk } ships 0.20 kB gzipped. |
| Immutable | No exported function mutates its input. Ever. |
| TypeScript-first | Written in strict TS, generics that infer, no any in the public API. |
| Universal | Vue, React, Nuxt, Next, Svelte, Node, browsers, workers — no Node built-ins. |
| ESM + CJS | Modern exports map with import/require/types conditions. |
| Tested | 363 tests, 99.4% line coverage; ESM + CJS consumers typechecked against the real tarball. |
Why modern-fns?
Most JavaScript utility libraries were designed before ES modules, before TypeScript was the default, and before bundle size was a shipping constraint. modern-fns is built for the way applications are written now.
- Zero runtime dependencies — nothing but the platform, enforced by a CI check.
- Immutable operations — no exported function mutates its input, so
===change detection in Vue, React and Svelte stays correct. - TypeScript-first — written in strict TypeScript, types generated from the source, no
@types/*package to install and noanyin the public API. - Tree-shakeable — one function per file and
sideEffects: false, soimport { chunk }ships 0.20 kB gzipped rather than a library. - ESM and CommonJS — a modern
exportsmap with per-conditiontypes, verified by typechecking real ESM and CJS consumer projects in CI. - Per-function imports —
modern-fns/array/chunkresolves to exactly one module. - Runs anywhere — Node 18+, all modern browsers, and any runtime with ES2021 and
Intl. The package imports no Node built-ins, which CI enforces.
modern-fns vs Lodash
Lodash is the reference point for this category, so here is an honest comparison. Facts about
Lodash below were checked against [email protected] on the npm registry.
| | modern-fns | Lodash |
| ---------------------------- | ------------------------------------ | ------------------------------------------------ |
| Runtime dependencies | 0 | 0 |
| Bundled TypeScript types | Yes, generated from source | No — install @types/lodash separately |
| ESM | Native | Separate lodash-es package |
| exports map | Yes, with import/require/types | No exports field |
| Tree-shaking from main entry | Yes (sideEffects: false) | Needs lodash-es or per-method imports |
| Per-function imports | modern-fns/array/chunk | lodash/chunk |
| Immutability | Every function | Mixed — set, pull, remove, assign mutate |
| Selector ergonomics | keyof T or a function, everywhere | Strings, paths, objects, matchers ("iteratee") |
| API surface | 145 functions | ~300 functions |
When Lodash is still the better choice: you need its breadth (_.template, _.curry, the
full fp module), you are on a legacy CommonJS toolchain, or you want a library with a decade of
production hardening behind it. modern-fns deliberately covers less ground — see
Design philosophy for what it declines to reimplement.
Table of contents
- Why modern-fns?
- Quick start
- Import styles
- Modules
- Highlights
- TypeScript
- Immutability
- Bundle size and tree shaking
- Browser and runtime support
- Design philosophy
- API documentation
- Contributing
Quick start
import { changed, chunk, diffArray, mergeQuery, orderBy, pipe, toNumber } from 'modern-fns';
// Reshape API data
chunk(products, 24); // pages
orderBy(users, ['role', 'age'], ['asc', 'desc']); // multi-key sort, immutable
// Know exactly what changed
changed(pristine, form); // is the form dirty?
diffArray(oldRows, newRows, 'id'); // { added, removed, updated, unchanged }
// URLs without string surgery
mergeQuery('/products?page=2&sort=price', { page: 3, q: 'shoes' });
// '/products?page=3&sort=price&q=shoes'
// Trust nothing from the wire
toNumber('₹1,299.50'); // 1299.5
toNumber('abc', 0); // 0
// Compose
const toHandle = pipe((s: string) => s.trim(), slugify);Import styles
All three are tree-shakeable. Named imports are the recommended default.
// 1. Named — recommended
import { chunk, groupBy, diff } from 'modern-fns';
// 2. Subpath — maximally explicit, provably one module
import chunk from 'modern-fns/array/chunk';
import diff from 'modern-fns/diff/diff';
// 3. Namespaces — useful for the query module
import { array, object, string, number, url, query, value } from 'modern-fns';
query.parse('?page=2&tags=vue&tags=nuxt'); // { page: 2, tags: ['vue', 'nuxt'] }CommonJS works too:
const { chunk } = require('modern-fns');One naming rule to know: the
querymodule's functions (parse,get,set,has,merge,remove,toggle, …) are not flat-exported from the package root, because they would collide with theobjectmodule'sget/set/has. Usequery.parse(...)ormodern-fns/query/parse. Everything else is available as a named import.
Modules
| Module | What it covers | Docs |
| ------------ | ----------------------------------------------------------------------------- | ------------------------------------------ |
| array | 25 list utilities plus diffArray for list reconciliation | docs/array.md |
| object | Path get/set/unset, pick/omit, deep clone/merge, structural equality, flatten | docs/object.md |
| diff | Structural change sets: dirty checking, audit logs, PATCH, undo/redo | docs/diff.md |
| string | Case conversion, slugs, truncation, masking, sanitising, extraction | docs/string.md |
| number | Decimal-safe maths, percentages, business maths, Intl formatting | docs/number.md |
| url | Absolute and relative URL manipulation, query and hash editing | docs/url.md |
| query | Query-string parse/stringify with configurable rules and typed readers | docs/query.md |
| value | Safe coercion and type guards for untrusted input | docs/value.md |
| functional | pipe, memoize, debounce, throttle, tryCatch, … | docs/functional.md |
| collection | One iteration API across arrays, objects, Map, Set, iterables | docs/collection.md |
The full signature list lives in the API specification.
Highlights
Immutable deep updates that keep references stable
import { set, unset, get } from 'modern-fns';
const next = set(state, 'user.profile.city', 'Kochi');
next !== state; // true — the path was copied
next.user.roles === state.user.roles; // true — untouched branches keep their reference
get(data, 'users[0].address.city', 'n/a'); // paths support brackets and defaultsThat reference stability is what makes === change detection in Vue, React and Svelte cheap and
correct.
A diff you can actually use
import { diff, changed, patch } from 'modern-fns';
changed(pristine, form); // enable/disable Save
diff(pristine, form);
// [
// { path: 'name', type: 'changed', oldValue: 'John', newValue: 'Vishnu' },
// { path: 'address.city', type: 'added', newValue: 'Kochi' },
// ]
patch(pristine, diff(pristine, form)); // structurally equal to `form`Configurable array strategies ('index', 'whole', 'key'), custom equality per path, and an
ignore hook for server-managed fields like updatedAt.
List reconciliation
import { diffArray } from 'modern-fns';
diffArray(oldUsers, newUsers, 'id');
// added: items only in the new list
// removed: items only in the old list
// updated: [{ key, before, after, changes: [...] }] — with field-level changes
// unchanged: deep-equal itemsReordering is not a change. An edit is one updated entry, not a delete plus an insert.
URLs and query strings that survive real input
import { setQuery, mergeQuery, isSameUrl } from 'modern-fns';
import { query } from 'modern-fns';
setQuery('/products?page=2', 'sort', 'price'); // '/products?page=2&sort=price'
mergeQuery('/p?page=2&sort=price', { sort: undefined }); // '/p?page=2'
isSameUrl('/p?a=1&b=2', '/p/?b=2&a=1'); // true
query.parse('?page=2&tags=vue&tags=nuxt'); // { page: 2, tags: ['vue', 'nuxt'] }
query.toggle('?page=2', 'inStock'); // '?page=2&inStock=true'Relative in, relative out. Arrays, nested keys, hashes and encoding all handled.
Coercion without the JavaScript traps
import { toNumber, toBoolean, isEmpty, isNumeric } from 'modern-fns';
toNumber('₹1,299.50'); // 1299.5 (currency and separators)
toBoolean('false'); // false (Boolean('false') is true)
isNumeric(''); // false (Number('') is 0)
isEmpty(0); // false (0 is a value, not an absence)Every conversion is documented as an input/output table — see docs/value.md.
Numbers that behave
import { round, abbreviate, currency, percentage } from 'modern-fns';
round(1.005, 2); // 1.01 (Math.round gives 1)
abbreviate(1500000); // '1.5M'
currency(1299.5, 'INR', 'en-IN'); // '₹1,299.50'
percentage(5, 0); // 0 (not NaN)TypeScript
Written in TypeScript with strict: true, shipped with complete .d.ts files and source maps.
No any in the public API.
Generics infer through the call.
const byRole = groupBy(users, 'role'); // Record<string, User[]>
const [active, rest] = partition(users, (u) => u.isActive); // [User[], User[]]
const names = compact(list); // (string | null)[] -> string[]Property selectors are checked.
groupBy(users, 'role'); // ok
groupBy(users, 'rolle'); // compile error: not a keyof User
groupBy(users, (u) => u.role.toUpperCase()); // functions welcome tooType guards narrow.
if (isString(input)) input.trim(); // input: string
const numbers = mixed.filter(isNumber); // number[]Discriminated unions where they help.
for (const change of diff(before, after)) {
if (change.type === 'added') change.newValue; // no `oldValue` in scope
}Overloads for real signatures.
toNumber('42'); // number
toNumber(input, 0); // number
toDate(value); // Date | undefined
toDate(value, new Date()); // Date
pipe(fetchUser, (r: Response) => r.json()); // returns a Promise — inferred
pipe((n: number) => n + 1); // stays synchronous — inferredImmutability
Every transformation returns a new value. Nothing sorts in place, splices your array or writes to your object.
const original = { user: { name: 'John' } };
const updated = set(original, 'user.name', 'Vishnu');
original.user.name; // 'John'Three functions own internal state, and say so in their docs: debounce, throttle and
memoize. tap invokes a callback you provide — if that callback mutates, tap cannot stop it.
Nothing else in the library holds state.
set and unset also refuse paths containing __proto__, constructor or prototype, so a
path from user input cannot pollute Object.prototype.
Bundle size and tree shaking
The published dist/ mirrors src/ file for file — the package is compiled with tsc, not
bundled. Your bundler sees one function per module and can drop everything else without
heuristics. sideEffects: false is declared, and no module runs code at import time.
Measured with esbuild (minified + gzipped), enforced in CI by npm run size:
| Import | Gzipped |
| --------------------------------- | -------- |
| modern-fns/array/chunk | 0.20 kB |
| modern-fns/object/set | 0.53 kB |
| modern-fns/value/toNumber | 0.40 kB |
| modern-fns/functional/debounce | 0.33 kB |
| modern-fns/diff/diff | 1.35 kB |
| modern-fns/array (whole module) | 2.91 kB |
| modern-fns (everything) | 12.83 kB |
// Ships one function, not a library.
import { chunk } from 'modern-fns';Verified with Vite, Rollup, webpack 5 and esbuild. If your bundler is configured for CJS only, prefer subpath imports — CJS cannot be tree-shaken by anyone.
Browser and runtime support
| Environment | Minimum | | ------------------------------------------ | ------- | | Node | 18 | | Chrome / Edge | 90 | | Firefox | 90 | | Safari | 15 | | Deno, Bun, Cloudflare Workers, Vercel Edge | current |
Node and browser figures are the supported floor; the ESM build is exercised on Node 16 and 24 and in Chrome in CI. Deno, Bun and edge runtimes are expected to work because the package uses no Node built-ins, but they are not part of the test matrix.
The library targets ES2021 and uses only Intl.NumberFormat, URL, String.prototype.normalize
and standard collections. There are no Node built-ins, no DOM requirements and no polyfills — the
same file runs in a browser, a server and a worker.
Design philosophy
Every function had to answer one question before it was added:
What recurring developer pain does this eliminate?
That is why this is not a Lodash clone. There is no map, filter, head, sum, isNaN or
forEach shim — the native versions are good. What is here is the code you would otherwise write
by hand and get subtly wrong:
round(1.005, 2)is1.01. The naive version gives1.sortByreturns a new array.Array.prototype.sortmutates.toBoolean('false')isfalse.Boolean('false')istrue.isNumeric('')isfalse.Number('')is0.zippads to the longest input instead of silently dropping data.random(1, 6)includes6.abbreviate(1999)is'1.9K'— a count never reads higher than it is.pickskips absent keys instead of addingundefinedto yourPATCHbody.unseton an array index splices instead of leaving a hole.debouncereturnscancel(), so unmounting does not set state on a dead component.
And where a name is risky, there is an escape hatch: slidingWindow aliases window so a named
import cannot shadow the DOM global, and the query module is namespaced away from object.
Priorities, in order: practicality, predictable APIs, type safety, small bundles, tree shaking, immutability, documentation, edge-case handling, composability.
API documentation
Full documentation with a page per module is published at modern-fns.vercel.app — see the Lodash migration guide and the comparison.
Per-function reference — name, description, signature, parameters, return value, examples, edge cases and TypeScript notes:
array · object · diff · string · number · url · query · value · functional · collection
Design notes: the API specification (signatures, naming conflicts, bundle strategy) and the API review (what was cut, what is on probation, and why).
Runnable examples: a Node tour and a Vue 3 playground that exercises every exported function with live, editable inputs.
Every function also carries full JSDoc, so your editor shows the same information on hover.
Contributing
See CONTRIBUTING.md — it covers the local workflow and the bar a new utility has to clear.
npm install
npm run dev # vitest, watch mode
npm run ci # lint + typecheck + coverage + build + verify + sizeSupport
modern-fns is free and dependency-free, and stays that way. If it saved you an afternoon, you can buy me a coffee.
License
MIT © Vishnu M
