underscore
Underscore is the original JavaScript utility belt, published in 2009 and still shipping. It exports a single function, conventionally named _, with about 150 helpers hanging off it: collection operations (map, filter, groupBy, sortBy, countBy), object operations (pick, omit, extend, defaults, isEqual), function decorators (debounce, throttle, memoize, once, partial), and a tiny string template compiler. It touches no built-in prototypes, has zero dependencies, and comes to roughly 22 kB minified. Most of what it was invented for is now built into the language, which is why the honest question about Underscore in 2026 is usually whether you can remove it rather than how to add it.
A well-built library that history has mostly overtaken: the language absorbed the collection helpers and lodash absorbed the rest. Keep it in a codebase that already has it, and reach for native methods or es-toolkit in anything new.
Use it if
- You maintain a codebase that already imports it, most likely a Backbone application, and you need it to keep working without a rewrite
- You want the handful of helpers the language still lacks: _.debounce, _.throttle, _.memoize, _.once and _.isEqual for deep comparison
- You need a dependency-free utility belt you can drop in with a script tag and use as a global, with no bundler and no build step
- You are on lodash and want a smaller footprint for the same core set: Underscore is roughly a third of lodash minified with a similar collection API
- You are writing something that must run on very old browsers, since the UMD build targets an era that predates most of what replaced it
- You are starting something new. Array.prototype.map, filter, find, some, every, flat and includes, plus Object.entries, Object.values, Object.groupBy and structuredClone, cover most of the library natively with no import at all.
- You already depend on lodash. Lodash is a superset with deep clone, deep merge and the by-iteratee variants Underscore never grew, and shipping both utility belts is pure duplicated bytes.
- You need deep operations. There is no cloneDeep and no deep merge here. _.clone is one level, _.extend is one level, and _.isEqual is the only deep thing in the box.
- You care about tree shaking from the default entry. The main export is a UMD CommonJS bundle with everything in it, so importing _ pulls the whole 22 kB unless you import from underscore/modules/ per function.
- You want bundled TypeScript types. There is no types field in the package, so you install @types/underscore separately and hope it tracks the runtime.
- You expect active feature development. The 1.13 line has been going since April 2021 and shipped only patches since: 1.13.7 in July 2024 and 1.13.8 in February 2026. That is maintenance, not momentum.
Setup reality
npm install underscore and you are done: zero dependencies, no peer dependencies, no config. The friction is elsewhere. The default import gives you the whole bundle, so if bundle size matters you need per-function imports from underscore/modules/ (ESM) or underscore/cjs/ (CommonJS), and those paths are stable but almost nobody documents them. Types are a separate install of @types/underscore. If you are loading it from a script tag alongside another library that also claims the _ global, call _.noConflict() to hand the name back. And the package's exports map is unusually branchy, with different files for node import, node require, browser and a production condition, which has historically confused bundlers and test runners that resolve conditions differently from your runtime.
Patterns
Import it in ESM, CommonJS, or a script tagimport-underscore
// ESM
import _ from 'underscore'
// CommonJS
const _ = require('underscore')
// browser, no bundler
// <script src="https://unpkg.com/underscore"></script>
// window._ is now definedThe default export is the whole library. There is no named-export form from the package root, so import { map } from "underscore" does not work the way it does in es-toolkit.
Import only the functions you usecherry-pick-imports
import debounce from 'underscore/modules/debounce.js'
import groupBy from 'underscore/modules/groupBy.js'
const onScroll = debounce(handler, 150)
const byType = groupBy(items, 'type')This is the only reliable way to avoid shipping the full 22 kB bundle, since the default entry is a UMD file that bundlers cannot shake. Use underscore/cjs/ instead of underscore/modules/ from CommonJS.
Group, index, count and partition a listgroup-and-count
const users = [
{ name: 'ana', role: 'admin', age: 34 },
{ name: 'bo', role: 'user', age: 28 },
{ name: 'cy', role: 'admin', age: 41 },
]
_.groupBy(users, 'role') // { admin: [ana, cy], user: [bo] }
_.countBy(users, 'role') // { admin: 2, user: 1 }
_.indexBy(users, 'name') // { ana: {...}, bo: {...}, cy: {...} }
_.partition(users, u => u.age > 30) // [[ana, cy], [bo]]
_.sortBy(users, 'age')Passing a string as the iteratee is shorthand for a property lookup. Object.groupBy is now native in modern runtimes and returns the same shape, so this is a good candidate for deletion.
Reshape objectspick-and-omit
_.pick(user, 'id', 'name') // keep only these keys
_.omit(user, 'password', 'token') // drop these keys
_.pick(user, (value, key) => value != null)
_.defaults({ port: 3000 }, { port: 8080, host: 'localhost' })
// { port: 3000, host: 'localhost' }
_.extend({}, defaults, overrides) // shallow merge, mutates the first arg_.extend mutates its first argument, which is why the idiom is to pass a fresh {}. There is no deep merge, so nested objects are copied by reference.
Rate limit a callbackdebounce-and-throttle
const save = _.debounce(() => api.save(form), 500)
const onScroll = _.throttle(updatePosition, 100)
// leading edge instead of trailing
const onClick = _.debounce(submit, 300, true)
save.cancel() // drop a pending trailing calldebounce waits for the calls to stop; throttle guarantees at most one call per interval. Create the debounced function once outside your render or event-binding code, because a new debounce on every call never actually debounces anything.
Compare values deeplydeep-equality
_.isEqual({ a: [1, 2] }, { a: [1, 2] }) // true
_.isEqual(new Date(0), new Date(0)) // true
_.isEmpty({}) // true
_.isEmpty([]) // true
_.isMatch(user, { role: 'admin' }) // subset check_.isEqual is the one genuinely deep operation in the library and it handles Dates, RegExps, typed arrays and cycles. It is not a substitute for a deep clone, which Underscore does not have.
Common array manipulationsarray-helpers
_.uniq([1, 2, 2, 3]) // [1, 2, 3]
_.uniq(users, false, u => u.email) // unique by a key
_.chunk([1, 2, 3, 4, 5], 2) // [[1, 2], [3, 4], [5]]
_.flatten([1, [2, [3, [4]]]]) // [1, 2, 3, 4], deep by default
_.flatten([1, [2, [3]]], true) // [1, 2, [3]], one level
_.difference([1, 2, 3], [2]) // [1, 3]
_.range(0, 10, 2) // [0, 2, 4, 6, 8]_.flatten is deep unless you pass true for shallow, which is the reverse of the native Array.prototype.flat depth argument and trips people up regularly.
Chain operationschaining
const result = _.chain(users)
.filter(u => u.age > 25)
.sortBy('age')
.pluck('name')
.value()Forgetting .value() is the classic bug: you get a wrapper object rather than an array, and console.log makes it look almost right. Chaining also forces the full bundle, since cherry-picked imports cannot be chained.
Wrap and memoize functionsfunction-decorators
const init = _.once(setup) // runs at most one time
const slowFib = _.memoize(n => (n < 2 ? n : slowFib(n - 1) + slowFib(n - 2)))
const greetAna = _.partial(greet, 'ana')
const later = _.delay(notify, 1000, 'done')_.memoize keys the cache on the first argument by default, so multi-argument functions silently return wrong results unless you pass a hasher function as the second argument.
Compile a small templatestring-templates
const tmpl = _.template('Hello <%= name %>, you have <%- count %> messages')
tmpl({ name: 'Ana', count: 3 })
_.templateSettings = { interpolate: /\{\{(.+?)\}\}/g }
_.template('Hello {{ name }}')({ name: 'Ana' })<%= %> interpolates raw and <%- %> HTML-escapes, so any user-controlled value belongs in the second form. The compiler builds a function with new Function, which a strict Content-Security-Policy without unsafe-eval will block.
Add your own helpers with mixinextend-underscore
_.mixin({
capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
},
})
_.capitalize('hello') // 'Hello'
_.chain('hello').capitalize().value()mixin registers on the shared _ object, so two libraries in the same page that both mixin the same name will clobber each other. It is fine in an application, questionable in anything you publish.
Give the _ global backavoid-global-clash
// after another library has also taken window._
const us = _.noConflict()
us.map([1, 2, 3], n => n * 2)noConflict restores whatever owned window._ before Underscore loaded and returns the Underscore object for you to bind to a name of your choosing. Only relevant for script-tag usage; bundled imports never touch the global.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| es-toolkit | npm | You want a modern, typed, tree-shakeable utility set built for current JavaScript |
| lodash | npm | You need deep clone, deep merge and the wider by-iteratee family and can afford the extra weight |
| remeda | npm | You want TypeScript-first utilities with data-last piping and inferred types |