How-to › Maintain libraries across languages

How to decide whether to depend on, copy, or write a utility#

Check each small dependency for a built-in, a risky edge, a one-line rewrite, or code you can copy, and test the replacement before the package goes.

Audience
Library maintainer
Level
beginner
Topic
Choose and build small utilities
Languages
TypeScript
Verified

You need one small function, such as padding a string or escaping a regular expression, and whoever types npm install first decides how it arrives. Months later the lockfile holds packages the runtime has replaced. A helper copied out of a package keeps a bug that the package fixed, because no scanner knows the copy exists.

What you get

You will end up with a script that lists each direct dependency’s size, transitive packages, license, and built-in replacement, and a checklist that turns those columns into a verdict. This is for you if you maintain a library’s dependencies list.

Short answer

Decide each small dependency with a four-question checklist, in order, stopping at the first answer that settles it. Use a runtime built-in when one exists on your oldest supported runtime. Depend on the package when it handles an edge you would get wrong; glob syntax and random ids are two such edges. Write a one-expression replacement yourself, with a test. Copy small, finished, permissively licensed code, keeping its license notice. Depend on anything left.

You will need

A Node.js or TypeScript project with a package.json and a lockfile. Verified 2026-10-21 against Node 22.22.0, module-replacements 3.4.0, and the six packages the sample audits.

The sample is the project under audit. Its package.json lists six small dependencies, and the scripts read them from node_modules and package-lock.json with no network call. It leaves out maintenance activity, which needs the registry: npm view prints a package’s publish history, and services such as Socket score it.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
Copy the function into your repoA small, finished function with a permissive license and no built-in on your runtime floorEvery later fix is yours to notice and apply, because npm audit cannot see a copied fileThe function sits on a security edge, or still changes upstream
Depend via npmThe subject has edges you would get wrong, such as globs, dates, or parsersEvery transitive package, its maintainers, and its install scripts join your supply chainA built-in covers it, or the code is one expression
Use a runtime built-inThe runtime ships the same behavior on the oldest version you supportIt raises your runtime floor, and its options and output can differ from the package’sYou support a runtime that predates it
Write your ownOne expression or a few lines, where every edge case fits in one testEvery edge case the package handled becomes a test you have to think of yourselfThe edges are not obvious, as with randomness or glob syntax

A dependency brings in someone else’s fixes on their schedule. Their packages and maintainers come with it. A copy freezes the code at the version you took, so a fix upstream never reaches you unless you go and look. A built-in costs a runtime floor, which is a decision about your users. Your own code costs a test for every edge, and the is-odd case below shows how easy one is to miss.

List what each dependency brings

audit.ts reads three places in the project: package.json for the direct dependencies, node_modules for each package’s code and license, and package-lock.json for what each one pulls in and any deprecation notice. The last column comes from the module-replacements list that the e18e community maintains, which maps packages to built-ins, one-line snippets, and lighter packages.

export function audit(dir: string): Dependency[] {
  const pkg = readJson(join(dir, 'package.json'))
  const lock: Lock = readJson(join(dir, 'package-lock.json')).packages
  return Object.keys(pkg.dependencies ?? {}).sort().map((name) => {
    const root = join(dir, 'node_modules', name)
    const own = readJson(join(root, 'package.json'))
    const sources = jsFiles(root)
    return {
      name,
      version: own.version,
      files: sources.length,
      lines: sources.reduce((sum, file) => sum + lineCount(file), 0),
      pullsIn: pulledIn(lock, `node_modules/${name}`),
      license: own.license ?? 'none',
      deprecated: lock[`node_modules/${name}`]?.deprecated,
      replacement: replacementFor(name),
    }
  })
}

Only dependencies counts, because that is what your users install. Run the report:

node --experimental-strip-types report.ts
dependency            version  files  lines  pulls in   license  e18e replacement
escape-string-regexp  5.0.0        1     11  -          MIT      RegExp.escape (native, Node 24.0.0)
is-odd                3.0.1        1     25  is-number  MIT      snippet: (n % 2) === 1
left-pad              1.3.0        5    210  -          WTFPL    String.prototype.padStart (native, Node 8.0.0)
minimist              1.2.8       17   1169  -          MIT      util.parseArgs (native, Node 16.17.0)
picomatch             4.0.7        7   2465  -          MIT      -
uuid                  14.0.3      44   1087  -          MIT      crypto.randomUUID (native, Node 16.7.0)

deprecated on the registry
  left-pad: use String.prototype.padStart()

The file and line counts cover all the JavaScript that installs, so tests and duplicate builds count. uuid ships 44 JavaScript files for a job that crypto.randomUUID does when version 4 ids are all you need. is-odd pulls in is-number for a remainder check. left-pad carries a deprecation notice that names its own replacement.1

Turn the columns into a verdict

The checklist is four questions asked in order, and the first one that settles a dependency decides it. Three of them read the audit. The fourth, whether the package handles an edge you would get wrong, has no data source, so its answers live in edges.json where a reviewer sees them.

export function decide(dep: Dependency, floor: string, edges: Record<string, string>) {
  const r = dep.replacement
  if (r?.type === 'native' && r.node && atLeast(floor, r.node)) {
    return { verdict: 'built-in' as Verdict, reason: r.id }
  }
  if (Object.hasOwn(edges, dep.name)) return { verdict: 'depend' as Verdict, reason: edges[dep.name] }
  if (r?.type === 'simple') return { verdict: 'write' as Verdict, reason: r.example ?? r.id }
  if (dep.lines <= 50 && dep.pullsIn.length === 0 && PERMISSIVE.has(dep.license)) {
    const until = r?.type === 'native' ? `, until Node ${r.node} brings ${r.id}` : ''
    return { verdict: 'copy' as Verdict, reason: `${dep.lines} lines, ${dep.license}, pulls in nothing${until}` }
  }
  return { verdict: 'depend' as Verdict, reason: 'no built-in, and too large to own' }
}

The floor comes from engines.node in package.json, here Node 22. The report stops without that field, because the Node that runs it says nothing about your users’ oldest runtime. The edge lookup uses Object.hasOwn, so a package named constructor does not match the property every object inherits. The same command prints the verdicts after the table:

node --experimental-strip-types report.ts
verdicts for a Node 22 floor
  escape-string-regexp  copy      11 lines, MIT, pulls in nothing, until Node 24.0.0 brings RegExp.escape
  is-odd                write     (n % 2) === 1
  left-pad              built-in  String.prototype.padStart
  minimist              built-in  util.parseArgs
  picomatch             depend    glob syntax: globstars, braces, dotfiles, and escaping
  uuid                  built-in  crypto.randomUUID

escape-string-regexp lands on copy only because of the floor. Its built-in, RegExp.escape, arrived in Node 24,2 so the sample keeps the eleven-line function in copied/, with its MIT notice and a comment saying when to delete it. uuid has an edge too, randomness, but the built-in handles that properly, so the question order lets it win. The write verdict quotes the list’s snippet for is-odd, which the failures section shows to be wrong for negative numbers.

Prove the replacement on the calls you make

A built-in that covers a package’s purpose can still differ on your inputs. Test the two side by side before the package goes. minimist turns 8080 into a number, and util.parseArgs leaves it a string:

test('util.parseArgs reads the flags minimist read, with the port as a string', () => {
  const argv = ['--port', '8080', '--verbose', 'src']
  const before = minimist(argv, { boolean: ['verbose'] })
  const after = parseArgs({
    args: argv,
    options: { port: { type: 'string' }, verbose: { type: 'boolean' } },
    allowPositionals: true,
  })
  assert.equal(before.port, 8080)
  assert.equal(after.values.port, '8080')
  assert.equal(after.values.verbose, before.verbose)
  assert.deepEqual(after.positionals, before._)
})

The swap therefore needs a Number() at the call site, the kind of difference a test catches before a reviewer would.

Check it worked

Ten tests cover the verdicts, a Node 24 floor turning the copy into a built-in, a missing floor, a name every object inherits, and each replacement against the package it replaces.

node --experimental-strip-types --test deps.test.ts
1..10
# tests 10
# suites 0
# pass 10
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 301.155456

With # pass 10, each package can go in the order the verdicts give, one commit per package, so a regression points at a single swap.

When it goes wrong

The function you wrote disagrees with the package on negative numbers. The list’s snippet for is-odd, (n % 2) === 1, returns false for -3, because JavaScript’s remainder takes the sign of the dividend.3 Compare with zero, as own.ts does, and test negative inputs.

A host allowlist written as a glob admits a host you meant to block. Turning *.example.com into a regular expression without escaping leaves each dot matching any character, so attackerexample.com passes. Escape the literal parts with the copied function, or depend on a glob library.

node --experimental-strip-types pitfall.ts
n    is-odd  (n % 2) === 1  own isOdd
3    true    true           true
-3   true    false          true
4    false   false          false
-4   false   false          false

allow *.example.com
host                      unescaped  escaped  picomatch
api.example.com           true       true     true
attackerexample.com       true       false    false
api.example.com.evil.net  false      false    false

An id you wrote is guessable. Math.random is not a cryptographically secure source, so an id built from it can be predicted. Use crypto.randomUUID, which is why edges.json names uuid at all.

When not to do this

Do not copy code that sits on a security edge. minimist needed two prototype pollution fixes, CVE-2020-7598 and CVE-2021-44906. A project that depended on it received both as version bumps that npm audit reported. A project that copied an older version carries the flaws until someone reads the advisory and patches the copy by hand.

Do not swap a package for a built-in the day the built-in ships. Your users’ oldest runtime sets the floor, and the floor is a promise in engines, so raise it in a release that says so.

Do not run the checklist on transitive packages you do not choose. Change or drop the direct dependency that pulls them in, because a removed parent takes its children with it.

Do not take the replacement list as a verdict. It is a list of suggestions, and its snippet for is-odd is wrong for every negative odd number.

Last verified

Verified 2026-10-21 against Node 22.22.0, module-replacements 3.4.0, escape-string-regexp 5.0.0, is-odd 3.0.1, left-pad 1.3.0, minimist 1.2.8, picomatch 4.0.7, and uuid 14.0.3. Every output block is what the command preceding it printed, run in the page’s code directory after npm ci. No registry or scoring service was queried. The advisory facts are read from OSV, and the policy facts from npm’s documentation.

Footnotes

  1. The package left the registry on March 22, 2016, with 272 others its author unpublished at once. According to npm’s own account, dependent builds failed at hundreds a minute for two and a half hours. A replacement went up within ten minutes, but babel reached left-pad through line-numbers, which asked for version 0.0.3 exactly, so npm restored that version from a backup. Its unpublish policy refuses to remove a package that others depend on and offers deprecation instead, the state the package is in. ↩︎ Back to text

  2. The request is older than every package the sample installs. A message to es-discuss on June 13, 2010, proposed standardizing RegExp.escape and called it trivial to implement. The proposal that shipped in Node 24.0.0, on May 6, 2025, escapes anything that could cause what its README calls a context escape. Its output therefore escapes more than escape-string-regexp does, including a letter at the start of the string. ↩︎ Back to text

  3. Python made the other choice. Its reference says the modulo operator always yields a result with the same sign as its second operand. In Python, -3 % 2 is 1, so the same test, written n % 2 == 1, holds for every integer. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.