# How to choose between porting a library and reimplementing it

> Decide how a library reaches a second language by running one shared case against every candidate, and record which reimplementations already disagree with it.

Source: https://voxgig.com/howto/choose-between-a-port-and-a-reimplementation

- Audience: platform-team
- Level: beginner
- Languages: typescript, go, python
- Verified: 2026-10-21
- Published: 2026-10-20

## Short answer

Choose between porting and reimplementing with a shared case file. Write the behaviors your users rely on as JSON cases with your library's answers, run every candidate against them, and record which already disagree. Adopt a reimplementation such as pydash or gojq only where it agrees. Generated bindings from jsii keep one implementation and need Node.js in every consumer. Otherwise port your library, and hold every port to the same cases in CI.

---
## You will need

A library you already maintain in one language, one behavior of it that is easy to get subtly
wrong, and the runtimes of the candidates you shortlist. Verified 2026-10-21 against Node
22.22.0, lodash 4.18.1, Python 3.13.16, pydash 8.1.0, the jq 1.13.0 bindings for Python with
their bundled jq 1.8.2, Go 1.24.7, gojq 0.12.19, and lo 1.53.0. The sample runs every candidate
except jaq and jsii, which are costed from their documentation.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [gojq](https://pkg.go.dev/github.com/itchyny/gojq) | A Go program needs jq's language and jq's answers | A list of deliberate differences from jq in its README, object key order among them | Your library is not jq, so there is no reimplementation to adopt |
| [jaq](https://gedenkt.at/jaq/manual/) | A Rust program or a shell script wants jq's language with stricter semantics | A manual of departures from jq, such as `join` printing null where jq prints nothing, each one a case your users can hit | Callers already depend on the exact output of jq |
| [jsii](https://aws.github.io/jsii/user-guides/lib-author/) | A TypeScript library whose public API fits the jsii type system, called from Python, Java, Go, or C# | A Node.js process beside every consumer, and an exported API that cannot declare its own generic types | Consumers cannot ship a Node.js runtime, or calls are small and frequent |
| [pydash](https://pydash.readthedocs.io/en/latest/api.html) | Python callers want lodash's names and most of its behavior | Drift wherever no test compares it with lodash, including a merge difference its own list of differences omits | You need the exact answers of lodash rather than its names |
| [samber/lo](https://pkg.go.dev/github.com/samber/lo) | Go callers want generic helpers that read like idiomatic Go | Coverage chosen function by function, so a deep merge is absent and `Assign` merges one level only | The function you need is one that lo leaves out |
| [Sqids, a canonical with ports](https://github.com/sqids/sqids-spec) | A small algorithm whose every port must give identical answers | Every port is code somebody must own, and its FAQ recruits maintainers for ports that have none | Users value idiom over identical output, or nobody can review every language |

A reimplementation such as pydash or gojq costs you nothing to write, and its answers are
whatever its authors chose, which only a shared case can tell you. Generated bindings from jsii
run one implementation everywhere, at the price of a Node.js process beside every consumer.[^1]
Ports held to shared tests, as Sqids holds its ports to the specification's tests, cost the most
to write. They are also the only option where the code that disagrees is yours to fix.

## Write the case that splits implementations

Pick one behavior your users rely on that a careful author could still get wrong. A deep merge
where one side holds null and the other holds a nested object is a good first case. JavaScript
has two empty values, null and undefined, while Python and Go have one each, so every
reimplementation decides what null means. Then add neighbors that change one thing each.

```json title="cases.json"
[
  {
    "id": "null-beside-nested-object",
    "doc": "A null in the override replaces one nested value and keeps its sibling, and an object replaces a null.",
    "base": { "limits": { "rate": 10, "burst": 20 }, "proxy": null },
    "override": { "limits": { "rate": null }, "proxy": { "host": "proxy.example.com" } },
    "expect": { "limits": { "rate": null, "burst": 20 }, "proxy": { "host": "proxy.example.com" } }
  },
  {
    "id": "array-over-array",
    "doc": "An array in the override replaces the base array whole, rather than index by index.",
    "base": { "tags": ["blue", "green"] },
    "override": { "tags": ["red"] },
    "expect": { "tags": ["red"] }
  },
  {
    "id": "object-over-array",
    "doc": "An object in the override replaces an array, even when its keys look like indexes.",
    "base": { "ports": [80, 443] },
    "override": { "ports": { "0": 8080 } },
    "expect": { "ports": { "0": 8080 } }
  }
]
```

`expect` holds the answer your own library gives, and the `doc` line says why the case exists.
Each candidate is judged twice: against `expect`, and against the library it reimplements.

## Run every candidate on the shortlist

`compare.mjs` runs lodash in Node, pydash and the jq bindings in Python, and gojq and lo in Go.
It compares parsed values with their keys sorted, so key order alone never counts as a
disagreement.

```bash
node compare.mjs
```

```text output
same answer as cases.json
  jq            python, libjq  3 of 3
  gojq          go             3 of 3
  lodash.merge  javascript     1 of 3
  pydash.merge  python         2 of 3
  lo.Assign     go             2 of 3
same answer as the original
  gojq          jq            3 of 3
  pydash.merge  lodash.merge  2 of 3, differs on object-over-array
  lo.Assign     lodash.merge  0 of 3, differs on null-beside-nested-object, array-over-array, object-over-array
object-over-array
  lodash.merge  {"ports":[8080,443]}
  pydash.merge  {"ports":{"0":8080}}
```

gojq gives jq's answer on all three cases, so for this behavior the reimplementation holds. The
null case separates only lo from the rest, so the two neighbors carry the comparison. The
[lodash reference](https://lodash.com/docs/#merge) merges arrays and plain objects recursively,
while the [jq manual](https://jqlang.org/manual/) merges values only when both are objects.[^2]
So lodash writes `8080` into the array. pydash replaces the array instead, and its list of
differences from lodash does not mention it.[^3] lo has no deep merge, and `Assign` merges one level, so it
matches lodash on no case.

lodash itself gives your library's answer on one case of three. Adopting any lodash
reimplementation means adopting lodash's semantics for arrays, whatever its name for the function.

## Hold the ports you keep to the same cases

A port is your library rewritten in another language and held to your cases by a check you run.
The canonical merge is short, and each port is the same function in its own language.

```js title="merge.mjs"
// The canonical deep merge. Objects merge key by key; anything else in the override, null and
// arrays included, replaces what the base held. Neither input is modified.
export function deepMerge(base, override) {
  if (!isObject(base) || !isObject(override)) return override
  const merged = Object.entries(override).map(([key, value]) => [
    key,
    Object.hasOwn(base, key) ? deepMerge(base[key], value) : value,
  ])
  // Spread and fromEntries define each key, so "__proto__" stays data, as in the other ports.
  // Assigning out[key] would call the __proto__ setter and replace the result's prototype.
  return { ...base, ...Object.fromEntries(merged) }
}
```

`ports.json` names every implementation CI runs, the command that makes it answer the cases,
and who answers for it.

```json title="ports.json"
[
  { "file": "merge.mjs", "owner": "@lib-core", "command": ["node", "merge.mjs"] },
  { "file": "merge.py", "owner": "@lib-python", "command": ["python3", "merge.py"] },
  { "file": "merge.go", "owner": "@lib-go", "command": ["go", "run", ".", "port"] }
]
```

```bash
node parity.mjs
```

```text output
merge.mjs               @lib-core    3 of 3
merge.py                @lib-python  3 of 3
merge.go                @lib-go      3 of 3
```

Each implementation prints its answer to every case, and `parity.mjs` compares the answers with
`expect`. The script exits non-zero when any answer differs or any listed port has no owner, so
a parity claim becomes a build that can fail.

## Check it worked

Run the suite. The first test holds every listed port to the cases, the second shows an unlisted
port failing them, and the third is the record of who disagreed when you chose. The fourth checks
that every port keeps a key named `__proto__` as data, where assigning it in JavaScript would set
the merged object's prototype instead.

```js title="parity.test.mjs"
// The record of who disagrees today. An upgrade that changes any of these fails here, so the
// record is reread before anyone trusts it again.
test('the shortlist still disagrees where it did when the choice was made', () => {
  const answers = collect()
  assert.deepEqual(differsFromCases(answers, 'jq'), [])
  assert.deepEqual(differsFromCases(answers, 'gojq'), [])
  assert.deepEqual(differsFromCases(answers, 'lodash.merge'), [
    'array-over-array',
    'object-over-array',
  ])
  assert.deepEqual(differsFromCases(answers, 'pydash.merge'), ['array-over-array'])
  assert.deepEqual(differsFromCases(answers, 'lo.Assign'), ['null-beside-nested-object'])
  assert.deepEqual(differsFrom(answers, 'pydash.merge', 'lodash.merge'), ['object-over-array'])
  assert.deepEqual(differsFrom(answers, 'lo.Assign', 'lodash.merge').length, cases.length)
})
```

```bash
node --test parity.test.mjs
```

```text output
1..4
# tests 4
# suites 0
# pass 4
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 678.744084
```

`# pass 4` means every listed port matches the canonical on every case and has an owner, and
every candidate still disagrees exactly where it did. An upgrade of pydash or gojq that changes
an answer turns the third test red, which is the moment to reread the record.

## When it goes wrong

A port passes the cases it was written against and fails the ones added since. Nobody owns it,
so nothing runs the current cases against it, but its README still calls it a port.
Run the parity check with the port's command added.

```bash
node parity.mjs python3 stale/merge.py
```

```text output
merge.mjs               @lib-core    3 of 3
merge.py                @lib-python  3 of 3
merge.go                @lib-go      3 of 3
python3 stale/merge.py  no owner     2 of 3
  array-over-array: expected {"tags":["red"]}, got {"tags":["red","green"]}
```

The stale port merges lists index by index, as the first release of the canonical did. Give it an
owner and a line in `ports.json`, or take the word "port" out of its README. Projects with many
ports plan for it.[^4]

The jq and gojq answers differ although they hold the same values. The comparison read
serialized JSON, and the [gojq README](https://github.com/itchyny/gojq) lists not keeping
object key order among its differences from jq. Compare parsed values with sorted keys, as
`compare.mjs` does, unless key order is part of your contract, and then write a case for it.

## When not to do this

Do not port a library to a language where a reimplementation already gives your answers on every
case you care about. Adopt it, keep the case file, and run the cases against each upgrade.

Do not choose jsii for a library of small, frequent calls. Each call is serialized to JSON and
sent to a Node.js child process, a round trip that a function the size of a merge does not need.

Do not call a reimplementation a port in your documentation. A port carries your cases and an
owner. A library that shares your function names carries neither, and its answers are its own.

Do not read three passing cases as proof that two libraries agree. Failing cases prove
disagreement; passing ones only say the libraries agree on those inputs, so keep adding the
cases your users report.

## Related how-tos

- [Design the case shape of a language-neutral test corpus](/howto/design-the-case-shape-of-a-test-corpus)

- [Merge nested config objects with predictable precedence](/howto/merge-nested-config-objects-with-precedence)

- [Judge whether an unofficial SDK is safe to depend on](/howto/judge-an-unofficial-sdk-before-adoption)

- [Release every port of a library on one version number](/howto/release-every-port-on-one-version)

- [Share one implementation across languages with WebAssembly](/howto/share-one-implementation-across-languages-with-wasm)

## Last verified

Verified 2026-10-21 against Node 22.22.0, lodash 4.18.1, Python 3.13.16, pydash 8.1.0, the jq
1.13.0 bindings with jq 1.8.2, Go 1.24.7, gojq 0.12.19, and lo 1.53.0. Every output block is
what the command preceding it printed. jaq, jsii, and the Sqids ports were read from their
documentation, not run, and the stale port is a sample written for this page.

[^1]: The jsii [runtime architecture](https://aws.github.io/jsii/overview/runtime-architecture/)
bundles the JavaScript inside each generated library and starts a Node.js child process to run
it, exchanging JSON over standard input and output. That child then starts a second Node.js
process, so console output from the original library can be intercepted and encoded on its way
back. A Python program calling a jsii library therefore runs three processes, two of them
Node.js.

[^2]: Each library defines its merge in one sentence. The [jq manual](https://jqlang.org/manual/)
says that multiplying two objects merges them recursively, and that values for the same key are
merged with the same strategy when both are objects. The
[lodash reference](https://lodash.com/docs/#merge) says that array and plain object properties
are merged recursively, and adds, as a note, that the method mutates its destination.
The arrays in `array-over-array` come out exactly as each sentence says they will.

[^3]: pydash keeps a page of its
[differences from lodash](https://pydash.readthedocs.io/en/latest/differences.html). It lists
`snake_case` names, a trailing underscore on names that are Python keywords, a shorthand for
callbacks, a `memoize` that keys its cache on every argument, and the absence of `template`. It
does not mention `merge`, so a reader of that page alone would expect lodash's answer on
`object-over-array`.

[^4]: The Sqids [specification](https://github.com/sqids/sqids-spec) calls itself the guide for
future ports to other languages, and asks that all unit tests have matching results. The
[FAQ](https://sqids.org/faq) explains how to port it: fork, implement the spec with all the
tests, and open a pull request. The instructions end with an offer: if a port's repository has
no active maintainers, the project would be happy to invite you to manage it.