Lodash 0/8

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.

JavaScript@types/lodash
01

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.

Analogy firstIterateesImmutabilityMust know

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.

TermMeansExample
CollectionAn array or an object you walk throughorders, { a: 1, b: 2 }
IterateeThe function run on each item(o) => o.total
Property shorthandA key name instead of a function"total"
Matches shorthandAn object pattern to match{ paid: true }
Pair shorthandA key and a value to match["city", "Pune"]
PathA dotted or array route into nested data"data.user.city"
model.jsJS
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);
TerminalOutput
$ 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.

02

Arrays

Split, dedupe, compare and flatten lists without writing index maths.

chunkuniqBydifferenceflatten

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.

_.chunk(array, size)

Splits into groups of size. The last group may be shorter.

See the example
New arrayBatching
_.compact(array)

Drops falsy values: false, null, 0, "", undefined, NaN.

See the example
New arrayClean up
_.uniqBy(array, iteratee)

Keeps the first item for each key.

See the example
New arrayDedupe objects
_.difference(array, values)

Items in the first array but not in the second.

See the example
New arraySet logic
_.flattenDeep(array)

Flattens every level of nesting.

See the example
New arrayNative: flat(Infinity)
_.zip(...arrays)

Pairs items by index, like a zipper.

See the example
New arrayTranspose
arrays.jsJS
console.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]));
TerminalOutput
$ 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.

FunctionDoesNative close match
_.chunk(xs, n)Batches of nNone built in
_.uniq(xs)Unique primitives[...new Set(xs)]
_.uniqBy(xs, "id")Unique by keyNone built in
_.flatten / _.flattenDeepOne level / all levelsxs.flat() / xs.flat(Infinity)
_.head / _.lastFirst / last itemxs[0] / xs.at(-1)
_.take(xs, n)First n itemsxs.slice(0, n)
_.sortedUniq, _.pull, _.removeSorted dedupe, remove in placepull and remove mutate
03

Collections

Group, index, count, sort and split lists of records, the daily bread of report and API code.

groupBykeyByorderByMust know

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.

_.groupBy(coll, key)

Key to array of items. Many per key.

See the example
ObjectReports
_.keyBy(coll, key)

Key to one item. The last one wins.

See the example
ObjectLookups by id
_.countBy(coll, key)

Key to how many items share it.

See the example
ObjectStats
_.orderBy(coll, keys, orders)

Multi key sort with asc or desc per key.

See the example
New arrayStable
_.partition(coll, fn)

Two arrays: items that pass and items that fail.

See the example
Two arraysSplit
_.sumBy / _.maxBy(coll, key)

Totals and extremes by a key.

See the example
One valueAggregates
collections.jsJS
const 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));
TerminalOutput
$ 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 haveYou wantWrite
Rows with a cityRows per city_.groupBy(rows, "city")
Users with idsFind by id fast_.keyBy(users, "id")
Events with a typeCount per type_.countBy(events, "type")
OrdersNewest first, then biggest_.orderBy(o, ["at", "total"], ["desc", "desc"])
JobsDone and not done_.partition(jobs, "done")
04

Objects

Read deep paths safely, pick and drop keys, merge configs and compare or copy nested data.

getmergecloneDeepMust know

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.

objects.jsJS
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)));
TerminalOutput
$ 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.

FunctionMutates inputUse for
_.get(obj, path, fallback)NoReading optional nested data
_.set(obj, path, value)YesWriting a deep path, creating levels
_.pick / _.omitNoShaping responses
_.merge(target, ...src)Yes, targetDeep config layering
_.defaults(target, ...src)Yes, targetFill only missing keys
_.mapValues(obj, fn)NoTransform every value
_.cloneDeep(obj)NoIndependent copies
_.isEqual(a, b)NoDeep equality checks
05

Function helpers

Control when and how often functions run: debounce, throttle, memoize and once.

debouncethrottlememoizeMust know

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_.debounce(fn, 300)Runs once after 300 ms of silence.
Throttle_.throttle(fn, 300)Runs at most once every 300 ms.
timing.jsJS
// 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());
TerminalOutput
$ 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.

HelperUseful optionsWatch out for
_.debounce(fn, ms, opts)leading, trailing, maxWaitCall .cancel() on unmount or shutdown
_.throttle(fn, ms, opts)leading, trailingBoth edges fire by default
_.memoize(fn, resolver)Custom cache key via resolverCache grows forever; clear fn.cache
_.once(fn)NoneErrors are not retried
06

Strings and utilities

Convert naming styles, trim text for display, and reach for the small number and type helpers.

camelCasetruncaterangeisEmpty

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.

strings.jsJS
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));
TerminalOutput
$ node strings.js
orderCreatedAt
order-created-at
order_created_at
User First Name
Zod and Joi w...
007 &lt;b&gt;hi&lt;/b&gt;
[ 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.

InputFunctionOutput
"order created at"_.camelCaseorderCreatedAt
"OrderCreatedAt"_.kebabCaseorder-created-at
"orderCreatedAt"_.snakeCaseorder_created_at
"user_first_name"_.startCaseUser First Name
"hello"_.upperFirstHello
"<b>"_.escape&lt;b&gt;
07

Chaining and flow

Compose several steps into one readable pipeline, either wrapped with chain or as a reusable function with flow.

chainflowlodash/fp

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

pipeline.jsJS
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));
TerminalOutput
$ 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.

08

Native vs lodash

Modern JavaScript absorbed many lodash classics. Know which ones, and import only what you still need.

Native firstTree shakingSecurityMust know

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.

native.jsJS
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);
TerminalOutput
$ 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.

LodashNative todayKeep lodash when
_.groupByObject.groupBy(xs, fn)You need key shorthands
_.flattenDeepxs.flat(Infinity)Never, native is fine
_.uniq[...new Set(xs)]Never for primitives
_.getobj?.a?.b ?? fallbackThe path is dynamic, like "a.b.c"
_.cloneDeepstructuredClone(obj)You clone functions or class instances
_.lastxs.at(-1)Never
_.sortByxs.toSorted(cmp)You sort by several keys
_.isEqualNoneAlways
_.mergeSpread is shallow onlyAlways, for deep config
_.debounce / _.throttleNoneAlways
Go

Which one do I need?

Find your situation, take the tool in the middle column and copy the starting line.

SituationReach forStart with
Batch rows for a bulk APIArrays_.chunk(rows, 100)
Report totals per groupCollections_.groupBy then _.sumBy
Look records up by idCollections_.keyBy(users, "id")
Read an optional nested fieldObjects_.get(res, "data.user.city", null)
Layer config filesObjects_.merge({}, base, env, local)
Compare two payloadsObjects_.isEqual(a, b)
Search as the user typesFunctions_.debounce(search, 300)
Rate limit a noisy handlerFunctions_.throttle(send, 1000)
snake_case columns to JSONStrings_.mapKeys(row, (v, k) => _.camelCase(k))
Flatten, dedupe, clone simplyNative firstflat(), new Set(), structuredClone()

Credits

  • AuthorShree Kumar Sharma
  • DepartmentBackend Engineering
  • Co-AuthorClaude Design