mrkeyoor.com_
Thu 06 Aug 05:52 UTC
npmWeb Frontendupdated 06 Aug 2026

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.

Verdict

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.

API stability5/5The public surface has barely moved in a decade and 1.13 has shipped only patch releases since April 2021. Upgrading Underscore is the least likely thing in your dependency tree to break a build.
Docs4/5underscorejs.org is a single annotated page with the source next to every function, which is fast to search and genuinely readable. It is dated in style, says little about per-function imports or bundle size, and there is no TypeScript story on the site at all.
Maintenance3/5Not abandoned: the repo saw commits in July 2026 and 1.13.8 shipped in February 2026 after a 19 month gap. But releases are patch-only, there are 30 open issues (53 issues and PRs), and the project is clearly in preservation mode.
Ecosystem3/5Roughly 27.6M weekly downloads, though a large share of that is transitive pulls from older tooling rather than new adoption. Backbone still depends on it, and @types/underscore is maintained in DefinitelyTyped, but very little new work targets it.

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
Skip it if

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 defined

The 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 call

debounce 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

PackageRegistryPick it when
es-toolkitnpmYou want a modern, typed, tree-shakeable utility set built for current JavaScript
lodashnpmYou need deep clone, deep merge and the wider by-iteratee family and can afford the extra weight
remedanpmYou want TypeScript-first utilities with data-last piping and inferred types