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

Source: https://voxgig.com/howto/depend-copy-or-write-a-small-utility

- Audience: library-maintainer
- Level: beginner
- Languages: typescript
- Verified: 2026-10-21
- Published: 2026-10-20

## 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`](https://docs.npmjs.com/cli/v10/commands/npm-view/)
prints a package's publish history, and services such as [Socket](https://docs.socket.dev/docs/getting-started)
score it.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Copy the function into your repo](https://opensource.org/license/mit) | A small, finished function with a permissive license and no built-in on your runtime floor | Every later fix is yours to notice and apply, because `npm audit` cannot see a copied file | The function sits on a security edge, or still changes upstream |
| [Depend via npm](https://docs.npmjs.com/cli/v10/commands/npm-install/) | The subject has edges you would get wrong, such as globs, dates, or parsers | Every transitive package, its maintainers, and its install scripts join your supply chain | A built-in covers it, or the code is one expression |
| [Use a runtime built-in](https://e18e.dev/docs/replacements/) | The runtime ships the same behavior on the oldest version you support | It raises your runtime floor, and its options and output can differ from the package's | You support a runtime that predates it |
| [Write your own](https://nodejs.org/docs/latest-v22.x/api/test.html) | One expression or a few lines, where every edge case fits in one test | Every edge case the package handled becomes a test you have to think of yourself | The 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](https://github.com/e18e/module-replacements) list that the e18e community
maintains, which maps packages to built-ins, one-line snippets, and lighter packages.

```ts title="audit.ts"
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:

```bash
node --experimental-strip-types report.ts
```

```text output
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.

```ts title="decide.ts"
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:

```bash
node --experimental-strip-types report.ts
```

```text output
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`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/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`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilparseargsconfig) leaves
it a string:

```ts title="deps.test.ts"
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.

```bash
node --experimental-strip-types --test deps.test.ts
```

```text output
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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Remainder).[^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.

```bash
node --experimental-strip-types pitfall.ts
```

```text output
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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/random),
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](https://osv.dev/vulnerability/GHSA-vh95-rmgr-6w4m) and
[CVE-2021-44906](https://osv.dev/vulnerability/GHSA-xvch-5gv4-984h). 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.

## Related how-tos

- [Automate dependency updates with Dependabot or Renovate](/howto/automate-dependency-updates-with-dependabot-or-renovate)

- [Evaluate a third-party SDK before you depend on it](/howto/evaluate-a-third-party-sdk)

- [Package one utility for Node, Deno, Bun, and browsers](/howto/package-a-utility-for-node-deno-and-browsers)

- [Measure what a small dependency costs before you add it](/howto/measure-what-a-small-dependency-costs)

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

[^1]: The package left the registry on March 22, 2016, with 272 others its author unpublished at
once. According to [npm's own account](https://blog.npmjs.org/post/141577284765/kik-left-pad-and-npm),
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](https://docs.npmjs.com/policies/unpublish/) refuses to remove a package that
others depend on and offers deprecation instead, the state the package is in.

[^2]: The request is older than every package the sample installs. A message to es-discuss on
June 13, 2010, proposed standardizing [`RegExp.escape`](https://esdiscuss.org/topic/regexp-escape)
and called it trivial to implement. The [proposal](https://github.com/tc39/proposal-regex-escaping)
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.

[^3]: Python made the other choice. Its
[reference](https://docs.python.org/3/reference/expressions.html#binary-arithmetic-operations) 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.