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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| 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 | 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 | 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 | 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 | 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 | 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.
[
{
"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.
Related how-tos
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
-
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
-
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-arraycome out exactly as each sentence says they will. ↩︎ Back to text -
pydash keeps a page of its differences from lodash. It lists
snake_casenames, a trailing underscore on names that are Python keywords, a shorthand for callbacks, amemoizethat keys its cache on every argument, and the absence oftemplate. It does not mentionmerge, so a reader of that page alone would expect lodash’s answer onobject-over-array. ↩︎ Back to text -
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