How-to › Maintain libraries across languages

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.

Audience
Platform team
Level
beginner
Topic
Port a library across languages
Languages
TypeScript, Go and Python
Verified

Your TypeScript service merges configuration with lodash, and the Python service that reads the same files uses pydash, which reimplements lodash for Python. Each passes its own tests, but an override that sets ports to an object comes out as an array in TypeScript and as an object in Python. The difference was never decided, so no test in either repository checks it.

What you get

You will end up with a case file that splits candidates on behavior your users rely on, a record of which libraries disagree, and a parity check for your own ports. This is for you if your library needs a second language.

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
gojqA Go program needs jq’s language and jq’s answersA list of deliberate differences from jq in its README, object key order among themYour library is not jq, so there is no reimplementation to adopt
jaqA Rust program or a shell script wants jq’s language with stricter semanticsA manual of departures from jq, such as join printing null where jq prints nothing, each one a case your users can hitCallers already depend on the exact output of jq
jsiiA 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 typesConsumers cannot ship a Node.js runtime, or calls are small and frequent
pydashPython callers want lodash’s names and most of its behaviorDrift wherever no test compares it with lodash, including a merge difference its own list of differences omitsYou need the exact answers of lodash rather than its names
samber/loGo callers want generic helpers that read like idiomatic GoCoverage chosen function by function, so a deep merge is absent and Assign merges one level onlyThe function you need is one that lo leaves out
Sqids, a canonical with portsA small algorithm whose every port must give identical answersEvery port is code somebody must own, and its FAQ recruits maintainers for ports that have noneUsers 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.

[
  {
    "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.

node compare.mjs
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 merges arrays and plain objects recursively, while the jq 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.

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

[
  { "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"] }
]
node parity.mjs
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.

// 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)
})
node --test parity.test.mjs
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.

node parity.mjs python3 stale/merge.py
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 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.

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.

Footnotes

  1. The jsii 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. ↩︎ Back to text

  2. Each library defines its merge in one sentence. The jq 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 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. ↩︎ Back to text

  3. pydash keeps a page of its differences from lodash. 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. ↩︎ Back to text

  4. The Sqids specification calls itself the guide for future ports to other languages, and asks that all unit tests have matching results. The 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. ↩︎ 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.