~
Byte-identical runtimes across TypeScript, Python, and Go
all writing
·
  • #xpr-lang
  • #language design
  • #testing
  • #go
  • #python
  • #typescript

One Grammar, Three Runtimes: Keeping TypeScript, Python, and Go Byte-Identical

Making three runtimes produce identical output bytes sounds straightforward. War stories from xpr-lang's conformance suite across TS, Python, and Go.

The first time all three runtimes passed the same test, I felt invincible. TypeScript, Python, Go, all evaluating the same expression, all producing the same JSON output.

“This is going to be straightforward,” I thought.

Then I added a test case for 0.1 + 0.2.

TypeScript gave me 0.30000000000000004. Python also gave me 0.30000000000000004. Go’s fmt.Sprintf("%g", 0.30000000000000004) gave me 0.3. Same IEEE 754 value, same 64-bit float, three runtimes, and Go decided to be “helpful” by printing fewer digits.

That was the moment “byte-identical” stopped being a slogan and became a spec requirement. If xpr-lang was going to mean anything as a cross-runtime expression language, the output bytes had to match exactly. Not “close enough.” Not “semantically equivalent.” The same bytes.

What followed was months of discovering every place where three mature, well-designed languages quietly disagree about how numbers, strings, and data structures should behave. The grammar turned out to be the easy part.

Semantics are where languages really live.

The Architecture

Before the war stories, some context on how xpr-lang’s multi-runtime setup works.

xpr-lang architecture: the xpr-lang/xpr spec repository holds the EBNF grammar and a 601-case YAML conformance suite, which each of the three runtime repositories consumes as a git submodule before publishing to npm, PyPI, and Go modules.

The project starts from a formal EBNF grammar in the main repository. Each runtime (TypeScript, Python, Go) implements a hand-written recursive-descent parser that produces the same AST node types. Same node names, same field names, same tree shape.

If the grammar says a binary expression has left, operator, and right, all three runtimes use exactly those field names. The AST is the contract between the parser and evaluator, and it doesn’t vary across implementations.

The behavioral contract lives in a YAML conformance suite: 601 test cases, each specifying an expression, optional input data, and the exact expected output.

- description: "String concatenation with numbers"
  expression: '"count: " ++ 42'
  expected: "count: 42"

- description: "Nested optional chaining"
  expression: "a?.b?.c"
  input: { a: { b: { c: 99 } } }
  expected: 99

- description: "Array pipeline"
  expression: "[3, 1, 2] |> sort() |> reverse()"
  expected: [3, 2, 1]

CI runs all three runtimes against every case on every PR. The test runner compares output bytes, not parsed values. If TypeScript outputs {"a":1} and Go outputs {"a": 1} (note the space after the colon), that’s a failure.

This strictness is intentional. When a user evaluates an expression in a browser preview (TypeScript) and then the same expression runs in a Go backend service, the result has to be identical. Not “equivalent.” Identical.

This setup caught an astonishing number of bugs. Here are the ones that taught me the most.

The War Stories

Float Formatting

The 0.1 + 0.2 problem was just the beginning. Every language has its own default algorithm for converting a float to a string, optimized for that language’s conventions.

JavaScript’s Number.prototype.toString() uses the shortest representation that round-trips back to the same 64-bit float. Python 3’s repr() does the same, thanks to David Gay’s algorithm. Go’s %g verb in fmt also aims for shortest round-trip, but its definition of “shortest” differs at the edges.

The value 1e20 illustrates the problem:

// Go
fmt.Sprintf("%g", 1e20) // "1e+20"
// TypeScript
(1e20).toString() // "100000000000000000000"
# Python
repr(1e20)  # "1e+20"

Same float. Three opinions about whether to use scientific notation. Go and Python agree on the notation, but TypeScript decides the number is “small enough” to print in full.

The fix was writing a custom float-to-string function in each runtime that follows a single set of rules:

  1. Use shortest round-trip representation
  2. Always use scientific notation for magnitudes >= 1e21
  3. Never add trailing zeros
  4. Always include a digit before the decimal point

Roughly 80 lines of formatting code per runtime, all tested against the same set of float-formatting conformance cases.

I lost a weekend to the number -0.0. JavaScript distinguishes negative zero from positive zero. Python mostly doesn’t. Go does, but fmt hides it by default. The spec now says: output 0 for both. Five conformance cases exist solely to enforce this.

Integer Division and Modulo Signs

Here’s a question that sounds simple: what’s -7 / 2 as an integer?

Go says -3 (truncation toward zero). Python says -4 (floor division). Both are mathematically defensible. Both are called “integer division” in their respective documentation. They produce different answers for every negative dividend.

// Go: truncates toward zero
result := -7 / 2  // -3
mod := -7 % 3     // -1
# Python: floors toward negative infinity
result = -7 // 2  # -4
mod = -7 % 3      # 2

Modulo has the same problem. -7 % 3 is -1 in Go and 2 in Python, because modulo’s sign follows the division convention. JavaScript doesn’t have true integer division, so it wasn’t directly relevant, but xpr-lang needed a single answer.

The spec settled on truncation toward zero (Go’s behavior, also C99’s behavior) because it’s the most common convention across systems languages. The Python runtime overrides // and % with custom implementations that match.

- description: "Negative integer division truncates toward zero"
  expression: "-7 / 2"
  expected: -3

- description: "Modulo sign follows truncation convention"
  expression: "-7 % 3"
  expected: -1

- description: "Positive operands behave identically"
  expression: "7 % 3"
  expected: 1

String Ordering and Unicode

Sorting strings sounds like something every language has figured out. It hasn’t.

JavaScript’s Array.prototype.sort() compares UTF-16 code units by default. Python 3’s sorted() compares Unicode code points. For ASCII strings these are identical, which is exactly the kind of coincidence that hides bugs until a customer puts an emoji in their username.

The character é (U+00E9) sorts differently depending on whether you compare code units or code points when surrogate pairs are involved. And don’t get me started on locale-aware collation, which all three languages support but which produces different results depending on system locale settings.

xpr-lang’s spec mandates code-point comparison with no locale awareness. Sorting is binary, predictable, and identical everywhere. This means "Z" < "a" is true (uppercase letters have lower code points), which occasionally surprises users, but consistency across runtimes matters more than matching any particular cultural expectation.

The conformance suite includes test cases with mixed ASCII, accented characters, and emoji to make sure all three runtimes agree on sort order.

Regex Dialect Mismatches

Regular expressions seem universal until you look closely. Go uses RE2, which guarantees linear-time matching by excluding backreferences and lookaheads. JavaScript and Python both support backreferences and various lookahead/lookbehind constructs that RE2 rejects.

xpr-lang needed a regex feature set that works in all three. The answer was the RE2 subset: character classes, quantifiers, alternation, grouping, anchors. No backreferences, no lookahead, no lookbehind.

If a regex literal uses an unsupported feature, the parser rejects it at parse time, not at match time. This was a painful compromise. Lookbehind assertions are genuinely useful for certain text-processing tasks. But accepting a regex in TypeScript that would crash the Go evaluator violates the entire premise of the language.

The regex dialect is documented in the spec, and the YAML suite includes cases that verify both successful matches and expected parse-time rejections.

Sort Stability

Is sort() stable? That is, do elements that compare as equal preserve their original order?

JavaScript guarantees stable sort since ES2019. Python’s sorted() has been stable since… always, basically. Timsort is stable by design. Go’s sort.Slice was not stable before Go 1.19, and even after, sort.Slice documents no stability guarantee. You need sort.SliceStable explicitly.

xpr-lang’s sortBy() guarantees stability. The Go runtime uses sort.SliceStable. Several conformance cases test this by sorting arrays of objects by one field and verifying that ties preserve the original input order.

- description: "Sort stability preserves original order for ties"
  expression: |
    [{name: "b", score: 1}, {name: "a", score: 1}, {name: "c", score: 1}]
      |> sortBy("score")
  expected:
    - { name: "b", score: 1 }
    - { name: "a", score: 1 }
    - { name: "c", score: 1 }

Number Type Unification

This was the deepest rabbit hole.

Go has int and float64 as distinct types. Python has int (arbitrary precision) and float. JavaScript has only Number (float64), though BigInt exists separately.

xpr-lang needs to behave identically in all three. The spec defines two numeric types: integer and float. Integer arithmetic stays integer when possible (3 * 4 is 12, not 12.0). Any operation involving a float produces a float (3 * 4.0 is 12.0). Division always promotes to float (10 / 3 is 3.333..., not 3). Integer division is a separate operator.

The tricky part is JSON output. The number 42 should serialize as 42, not 42.0. The number 42.0 (resulting from float arithmetic) should serialize as 42.0. JavaScript has no native way to distinguish these, since both typeof 42 and typeof 42.0 return "number".

The TypeScript runtime tracks “integer vs float” as metadata attached to values throughout evaluation, then uses that metadata during serialization. Go’s version was simpler, since the type system already distinguishes int64 from float64. Python’s was somewhere in between, requiring careful isinstance() checks that survived arithmetic operations.

- description: "Integer stays integer"
  expression: "3 * 4"
  expected: 12

- description: "Float promotion on mixed operands"
  expression: "3 * 4.0"
  expected: 12.0

- description: "int() returns integer type"
  expression: "int(3.7)"
  expected: 3

Twenty-three conformance cases test various paths through number type promotion, serialization, and edge cases.

The Test-First Workflow

After enough war stories, a pattern emerged. Every new feature starts life as a set of YAML test cases. Not code. Not even grammar updates. Tests first.

The workflow looks like this:

  1. Write YAML cases describing the new behavior, including edge cases and error conditions
  2. Update the EBNF grammar if new syntax is needed
  3. Implement in TypeScript (usually the fastest to prototype in)
  4. Run the conformance suite to verify no regressions and that new cases pass
  5. Port to Python, run the suite again
  6. Port to Go, run the suite again
  7. PR only merges when all three runtimes pass every case

This workflow catches cross-runtime bugs before they reach users. It also forces me to think about edge cases before I start coding, because writing a YAML case is cheap and changing an implementation later is expensive.

When I added the pipe operator, the YAML cases took an afternoon. The TypeScript implementation took a day. The Python and Go ports each took another day. But the cases I wrote up front caught eleven bugs across the three implementations, including three I never would have found through manual testing.

What Byte-Identical Buys You

All this effort produces one simple guarantee for users: an expression evaluated in a browser preview produces the same output as the same expression evaluated in a Go backend service.

This matters in practice. A product manager can write a transform expression in the playground, test it against sample data, see the output, and know that production will produce exactly those bytes. There’s no “works in preview, breaks in production” class of bugs.

There’s no “the Python batch job and the Go streaming service disagree” incident.

It also makes debugging straightforward. When something goes wrong with an expression, you can reproduce it in any runtime. The bug is either in the expression or in the data. It’s never “this runtime interprets it differently.”

For anyone building systems that span multiple languages (and most data platforms do), byte-identical evaluation eliminates an entire category of integration bugs. The expression becomes a true contract, not an aspiration.

Lessons

A year of conformance testing taught me things I couldn’t have learned any other way.

The grammar is 10% of the work. Parsing a language from an EBNF spec is a well-understood problem. Making three implementations agree on every semantic edge case is where you’ll spend your weekends.

Default behaviors vary more than you think. Every language makes hundreds of small decisions about formatting, coercion, ordering, and precision. These decisions are all “correct” within their own ecosystems. They only become bugs when you need multiple ecosystems to agree.

Test the output, not the logic. Comparing output bytes is a blunt instrument, but it catches bugs that semantic comparison would miss. If the JSON key order differs, or a number gains a trailing .0, or a unicode character normalizes differently, byte comparison catches it immediately.

Write the tests before the code. This isn’t news, but it’s especially powerful when your test suite is the authoritative behavioral spec shared across multiple implementations. The YAML cases are the source of truth. Everything else is commentary.

The conformance suite currently has 601 cases covering arithmetic, string operations, array methods, object manipulation, pipe operators, optional chaining, destructuring, error conditions, and a dozen other feature areas. Every one of those cases exists because at least one runtime got it wrong at some point.

You can explore the project at /projects/xpr-lang or try expressions in the browser playground.


If you’re curious about why I built xpr-lang in the first place, start with Why I Built an Expression Language Nobody Asked For. If you want to understand the parser that makes all this possible, check out Write a Recursive-Descent Parser by Hand.