Backend engineeringUtility functionsLodash 4.18
Lodash
Toolbox
Lodash is the utility drawer of JavaScript: small, sharp tools for grouping, reading, merging and timing, each tested on the edge cases you would forget. Here are the ones you reach for daily, runnable in the page, plus which ones modern JavaScript now does for free.
The mental model
Lodash is the utility drawer in a busy kitchen: small, sharp tools that each do one job, so you stop writing the same loop by hand.
Every backend grows the same helpers: group these rows by city, read a deeply nested field without crashing, wait until the user stops typing. Lodash is the drawer where those tools already live, tested on every edge case you would forget, such as sparse arrays, null inputs and objects that pretend to be arrays.
Two habits make it click. First, iteratee shorthands: most functions accept a key name instead of a callback, so _.map(orders, "city") reads the city of each order and _.filter(orders, { paid: true }) keeps the paid ones. Second, lodash is non mutating by default: it hands you a new array or object and leaves the input alone, except for the few functions whose names say they change things, such as merge, set, assign and remove.
| Term | Means | Example |
|---|---|---|
| Collection | An array or an object you walk through | orders, { a: 1, b: 2 } |
| Iteratee | The function run on each item | (o) => o.total |
| Property shorthand | A key name instead of a function | "total" |
| Matches shorthand | An object pattern to match | { paid: true } |
| Pair shorthand | A key and a value to match | ["city", "Pune"] |
| Path | A dotted or array route into nested data | "data.user.city" |
const orders = [
{ id: 1, city: "Delhi", total: 1200, paid: true },
{ id: 2, city: "Pune", total: 450, paid: false },
{ id: 3, city: "Delhi", total: 800, paid: true },
];
// Iteratee shorthands: a string means "read this key"
console.log(_.map(orders, "city"));
console.log(_.filter(orders, { paid: true }).length);
console.log(_.sumBy(orders, "total"));
console.log(_.find(orders, ["city", "Pune"]).id);
// Lodash never changes the input unless the name says so
console.log(orders.length, orders[0].city);
$ node model.js [ 'Delhi', 'Pune', 'Delhi' ] 2 2450 2 3 Delhi
Why it matters: shorthands turn four loops into four lines, and the last line proves the input array was not touched.
Arrays
Split, dedupe, compare and flatten lists without writing index maths.
Array helpers are the knives of the drawer. _.chunk cuts a list into batches, perfect for sending 500 rows to an API that accepts 100 at a time. _.uniqBy removes duplicates by a key, which native Set cannot do for objects. _.difference and _.intersection answer "what is missing" and "what is shared", such as permissions a user has lost.
Splits into groups of size. The last group may be shorter.
See the exampleDrops falsy values: false, null, 0, "", undefined, NaN.
See the exampleKeeps the first item for each key.
See the exampleItems in the first array but not in the second.
See the exampleFlattens every level of nesting.
See the examplePairs items by index, like a zipper.
See the exampleconsole.log(_.chunk([1, 2, 3, 4, 5], 2));
console.log(_.compact([0, "a", "", null, "b", false, undefined]));
console.log(_.uniq([3, 1, 3, 2, 1]));
console.log(_.uniqBy([{ id: 1 }, { id: 2 }, { id: 1 }], "id"));
console.log(_.difference(["read", "write", "admin"], ["admin"]));
console.log(_.intersection(["node", "go", "rust"], ["rust", "node"]));
console.log(_.flattenDeep([1, [2, [3, [4]]]]));
console.log(_.zip(["a", "b"], [1, 2]));
console.log(_.take([10, 20, 30, 40], 2), _.last([10, 20, 30]));
$ node arrays.js [ [ 1, 2 ], [ 3, 4 ], [ 5 ] ] [ 'a', 'b' ] [ 3, 1, 2 ] [ { id: 1 }, { id: 2 } ] [ 'read', 'write' ] [ 'node', 'rust' ] [ 1, 2, 3, 4 ] [ [ 'a', 1 ], [ 'b', 2 ] ] [ 10, 20 ] 30
Why it matters: each call answers one question in one line, and every result is a fresh array, so the originals can be reused safely.
| Function | Does | Native close match |
|---|---|---|
_.chunk(xs, n) | Batches of n | None built in |
_.uniq(xs) | Unique primitives | [...new Set(xs)] |
_.uniqBy(xs, "id") | Unique by key | None built in |
_.flatten / _.flattenDeep | One level / all levels | xs.flat() / xs.flat(Infinity) |
_.head / _.last | First / last item | xs[0] / xs.at(-1) |
_.take(xs, n) | First n items | xs.slice(0, n) |
_.sortedUniq, _.pull, _.remove | Sorted dedupe, remove in place | pull and remove mutate |
Collections
Group, index, count, sort and split lists of records, the daily bread of report and API code.
Collection helpers are the sorting trays. _.groupBy drops each record into a tray by a key and gives you arrays, while _.keyBy gives you exactly one record per key, which turns an array of users into a lookup table by id. _.countBy only counts, and _.orderBy sorts by several keys with a direction for each, something native sort makes you write by hand.
Key to array of items. Many per key.
See the exampleKey to one item. The last one wins.
See the exampleKey to how many items share it.
See the exampleMulti key sort with asc or desc per key.
See the exampleTwo arrays: items that pass and items that fail.
See the exampleTotals and extremes by a key.
See the exampleconst users = [
{ name: "Asha", team: "api", age: 29 },
{ name: "Ravi", team: "web", age: 34 },
{ name: "Meera", team: "api", age: 25 },
];
console.log(_.groupBy(users, "team").api.length);
console.log(_.keyBy(users, "name").Ravi.team);
console.log(_.countBy(users, "team"));
console.log(_.map(_.orderBy(users, ["team", "age"], ["asc", "desc"]), "name"));
console.log(_.partition(users, (u) => u.age >= 30).map((g) => g.length));
console.log(_.maxBy(users, "age").name, _.round(_.meanBy(users, "age"), 1));
$ node collections.js 2 web { api: 2, web: 1 } [ 'Asha', 'Meera', 'Ravi' ] [ 1, 2 ] Ravi 29.3
Why it matters: keyBy turns a search through an array into a direct property read, and orderBy sorts by team ascending then age descending in one call.
| You have | You want | Write |
|---|---|---|
| Rows with a city | Rows per city | _.groupBy(rows, "city") |
| Users with ids | Find by id fast | _.keyBy(users, "id") |
| Events with a type | Count per type | _.countBy(events, "type") |
| Orders | Newest first, then biggest | _.orderBy(o, ["at", "total"], ["desc", "desc"]) |
| Jobs | Done and not done | _.partition(jobs, "done") |
Objects
Read deep paths safely, pick and drop keys, merge configs and compare or copy nested data.
Objects are the containers in the drawer. _.get is a torch for dark cupboards: it walks a path and returns a default instead of throwing when a shelf is missing. _.pick and _.omit shape a response, such as removing a password hash before sending a user out.
_.merge blends configs deeply, so overriding db.pool keeps db.host. It mutates its first argument, which is why the example passes a fresh {} first. _.cloneDeep makes a fully separate copy and _.isEqual compares by value, the two things === and spread cannot do for nested data.
const res = { data: { user: { profile: { city: "Delhi" }, tags: ["a"] } } };
console.log(_.get(res, "data.user.profile.city"));
console.log(_.get(res, "data.user.phone", "not set"));
console.log(_.has(res, ["data", "user", "tags", 0]));
const cfg = { port: 3000, db: { host: "localhost", pool: 5 } };
console.log(_.pick(cfg, ["port"]), _.omit(cfg, ["db"]));
console.log(_.merge({}, cfg, { db: { pool: 20 } }));
console.log(_.defaults({ port: 8080 }, cfg).port);
const copy = _.cloneDeep(cfg);
copy.db.pool = 99;
console.log(cfg.db.pool, _.isEqual(cfg, _.cloneDeep(cfg)));
$ node objects.js Delhi not set true { port: 3000 } { port: 3000 } { port: 3000, db: { host: 'localhost', pool: 20 } } 8080 5 true
Why it matters: changing the deep copy left the original pool at 5, and isEqual says two separate objects with the same contents are equal.
| Function | Mutates input | Use for |
|---|---|---|
_.get(obj, path, fallback) | No | Reading optional nested data |
_.set(obj, path, value) | Yes | Writing a deep path, creating levels |
_.pick / _.omit | No | Shaping responses |
_.merge(target, ...src) | Yes, target | Deep config layering |
_.defaults(target, ...src) | Yes, target | Fill only missing keys |
_.mapValues(obj, fn) | No | Transform every value |
_.cloneDeep(obj) | No | Independent copies |
_.isEqual(a, b) | No | Deep equality checks |
Function helpers
Control when and how often functions run: debounce, throttle, memoize and once.
These are the kitchen timers. _.debounce waits for quiet: every call resets the timer, so a search box fires once after the user stops typing. _.throttle is a turnstile: calls pass at most once per window, so a scroll or metrics handler cannot flood the server.
_.memoize keeps a notebook of answers keyed by the first argument, so a slow pure function runs once per input. _.once is a fuse that burns once: perfect for opening a single database connection however many modules ask for it.
// debounce: wait until the typing stops, then run once
const search = _.debounce((q) => console.log("search:", q), 100);
search("z"); search("zo"); search("zod");
// throttle: run at most once per window, however often it fires
let calls = 0;
const onScroll = _.throttle(() => calls++, 100);
for (let i = 0; i < 50; i++) onScroll();
setTimeout(() => console.log("throttled calls:", calls), 300);
// memoize: cache by the first argument
const slowSquare = _.memoize((n) => { console.log("computing", n); return n * n; });
console.log(slowSquare(9), slowSquare(9));
// once: the second call returns the first result
const init = _.once(() => { console.log("connecting"); return "db"; });
console.log(init(), init());
$ node timing.js computing 9 81 81 connecting db db search: zod throttled calls: 2
Why it matters: three rapid searches become one call with the final query, and fifty scroll events become two: the leading call and one trailing call. The memoized square computes only once.
| Helper | Useful options | Watch out for |
|---|---|---|
_.debounce(fn, ms, opts) | leading, trailing, maxWait | Call .cancel() on unmount or shutdown |
_.throttle(fn, ms, opts) | leading, trailing | Both edges fire by default |
_.memoize(fn, resolver) | Custom cache key via resolver | Cache grows forever; clear fn.cache |
_.once(fn) | None | Errors are not retried |
Strings and utilities
Convert naming styles, trim text for display, and reach for the small number and type helpers.
String helpers are the label maker. Backend code constantly crosses naming styles: database columns in snake_case, JSON in camelCase, URLs in kebab-case. Lodash converts between them from any starting style, and _.escape makes text safe to drop into HTML.
The utility helpers fill small gaps: _.range and _.times build sequences, _.clamp keeps a page size inside limits, and _.isEmpty answers "is there anything here" for objects, arrays, maps and strings alike.
console.log(_.camelCase("order created at"));
console.log(_.kebabCase("OrderCreatedAt"));
console.log(_.snakeCase("orderCreatedAt"));
console.log(_.startCase("user_first_name"));
console.log(_.truncate("Zod and Joi walk into a bar", { length: 16 }));
console.log(_.padStart("7", 3, "0"), _.escape("<b>hi</b>"));
console.log(_.range(0, 20, 5), _.times(3, (i) => i * 2));
console.log(_.clamp(150, 0, 100), _.inRange(3, 1, 5));
console.log(_.isEmpty({}), _.isEmpty([0]), _.isNil(null));
$ node strings.js orderCreatedAt order-created-at order_created_at User First Name Zod and Joi w... 007 <b>hi</b> [ 0, 5, 10, 15 ] [ 0, 2, 4 ] 100 true true false true
Why it matters: isEmpty([0]) is false because the array has one item, even though that item is falsy. clamp is a tidy guard for a limit query parameter.
| Input | Function | Output |
|---|---|---|
"order created at" | _.camelCase | orderCreatedAt |
"OrderCreatedAt" | _.kebabCase | order-created-at |
"orderCreatedAt" | _.snakeCase | order_created_at |
"user_first_name" | _.startCase | User First Name |
"hello" | _.upperFirst | Hello |
"<b>" | _.escape | <b> |
Chaining and flow
Compose several steps into one readable pipeline, either wrapped with chain or as a reusable function with flow.
_.chain(value) puts your data on a conveyor belt. Each step reads like a sentence, group, map, order, take, and nothing leaves the belt until .value(). _.flow builds the belt without the data, giving you a function you can name, test and reuse.
There is a catch for bundles: chain pulls in the whole library, because any method could appear on the wrapper. If size matters, prefer flow with individually imported functions, or lodash/fp, a variant where every function takes the data last and is curried, so steps compose without wrappers.
const sales = [
{ city: "Delhi", amount: 1200 },
{ city: "Pune", amount: 450 },
{ city: "Delhi", amount: 800 },
{ city: "Goa", amount: 300 },
];
// chain wraps a value; nothing runs until .value()
const top = _.chain(sales)
.groupBy("city")
.map((rows, city) => ({ city, total: _.sumBy(rows, "amount") }))
.orderBy("total", "desc")
.take(2)
.value();
console.log(top);
// flow builds the same pipeline as a reusable function
const cityNames = _.flow([(xs) => _.map(xs, "city"), _.uniq, _.sortBy]);
console.log(cityNames(sales));
$ node pipeline.js [ { city: 'Delhi', total: 2000 }, { city: 'Pune', total: 450 } ] [ 'Delhi', 'Goa', 'Pune' ]
Why it matters: the chain reads top to bottom like a report spec: total sales per city, highest first, top two. The same idea as flow becomes a named function you can drop into any handler.
Native vs lodash
Modern JavaScript absorbed many lodash classics. Know which ones, and import only what you still need.
The language has caught up on a lot of the drawer: Object.groupBy, flat, Set, optional chaining with ??, structuredClone, at(-1) and toSorted now cover common cases with no dependency. What native still lacks is deep equality, a deep merge, debounce and throttle, the case converters and the multi key sort, which is where lodash keeps earning its place.
Import with care. import _ from "lodash" brings in the whole library; per method imports such as import debounce from "lodash/debounce", or the ES module build lodash-es, let a bundler keep only what you use. Older 4.17 releases had prototype pollution bugs in deep setters such as merge and set, so stay on the latest release and never deep merge a raw request body into config.
const users = [{ name: "Asha", team: "api" }, { name: "Ravi", team: "web" }];
// Native now covers many lodash classics
console.log(Object.keys(Object.groupBy(users, (u) => u.team)));
console.log([1, [2, [3]]].flat(Infinity), [...new Set([1, 1, 2])]);
console.log(users[0]?.profile?.city ?? "not set");
const copy = structuredClone({ a: { b: 1 } });
console.log(copy.a.b);
// But native has no deep equality or safe deep merge
console.log({ a: 1 } === { a: 1 }, _.isEqual({ a: 1 }, { a: 1 }));
const merged = { ...{ db: { host: "x", pool: 5 } }, ...{ db: { pool: 20 } } };
console.log(merged.db, _.merge({ db: { host: "x", pool: 5 } }, { db: { pool: 20 } }).db);
$ node native.js [ 'api', 'web' ] [ 1, 2, 3 ] [ 1, 2 ] not set 1 false true { pool: 20 } { host: 'x', pool: 20 }
Why it matters: spread only merges the top level, so db.host vanished, while _.merge kept it. Two identical object literals are never ===, which is why isEqual still matters.
| Lodash | Native today | Keep lodash when |
|---|---|---|
_.groupBy | Object.groupBy(xs, fn) | You need key shorthands |
_.flattenDeep | xs.flat(Infinity) | Never, native is fine |
_.uniq | [...new Set(xs)] | Never for primitives |
_.get | obj?.a?.b ?? fallback | The path is dynamic, like "a.b.c" |
_.cloneDeep | structuredClone(obj) | You clone functions or class instances |
_.last | xs.at(-1) | Never |
_.sortBy | xs.toSorted(cmp) | You sort by several keys |
_.isEqual | None | Always |
_.merge | Spread is shallow only | Always, for deep config |
_.debounce / _.throttle | None | Always |
Which one do I need?
Find your situation, take the tool in the middle column and copy the starting line.
| Situation | Reach for | Start with |
|---|---|---|
| Batch rows for a bulk API | Arrays | _.chunk(rows, 100) |
| Report totals per group | Collections | _.groupBy then _.sumBy |
| Look records up by id | Collections | _.keyBy(users, "id") |
| Read an optional nested field | Objects | _.get(res, "data.user.city", null) |
| Layer config files | Objects | _.merge({}, base, env, local) |
| Compare two payloads | Objects | _.isEqual(a, b) |
| Search as the user types | Functions | _.debounce(search, 300) |
| Rate limit a noisy handler | Functions | _.throttle(send, 1000) |
| snake_case columns to JSON | Strings | _.mapKeys(row, (v, k) => _.camelCase(k)) |
| Flatten, dedupe, clone simply | Native first | flat(), new Set(), structuredClone() |
Credits
- AuthorShree Kumar Sharma
- DepartmentBackend Engineering
- Co-AuthorClaude Design