Engineering guide

One Shared IR for Python and JavaScript Expressions

Lower Python and JavaScript expressions to one common intermediate representation, with AST examples, validation, an interpreter, and a small JavaScript emitter.

François Guéguen 13 min read

Consider an illustrative rule editor that accepts Python from one team and JavaScript from another. Both teams want to express the same shipping condition: the subtotal is at least 5,000 cents and the shopper is a member.

Python:

subtotal_cents >= 5000 and is_member

JavaScript:

subtotal_cents >= 5000 && is_member

You want one place to check and evaluate these rules. Adding a second source language should not require a second implementation of the shipping policy's execution machinery.

A common intermediate representation (IR) gives you that boundary. Each language has its own parser and adapter. Both adapters produce the same small representation, which a shared verifier and interpreter understand.

This article develops that design through an illustrative rule engine. It assumes familiarity with expressions, booleans, and basic data structures. The example accepts a small expression subset of Python and JavaScript; it is not a translation system for arbitrary programs or a report of customer work.

Define what the two languages are allowed to say

The shipping rule needs only two inputs:

  • subtotal_cents: an integer from 0 through 2147483647, inclusive.
  • is_member: an exact boolean, with no conversion from strings or numbers.

Accept these inputs as simple names, non-negative integer literals in the same range, boolean literals, >= between integers, and conjunction between booleans. Parentheses may group expressions. The whole rule must produce a boolean.

For this first version, each comparison must have exactly one comparator. Conjunctions can nest; the adapters preserve their left-to-right, short-circuit evaluation.

Reject everything else: calls, property access, arrays, strings, arithmetic, assignments, and additional statements. A valid Python expression such as check_membership() is therefore an unsupported rule, even though the Python parser accepts it.

That is an important distinction. The parser checks the language's grammar. Your adapter checks the smaller language your product promises to support.

Start with a rejection fixture:

Source: subtotal_cents >= 5000 and check_membership()
Result: unsupported function call at check_membership()

Supporting another construct is a design change. It needs a meaning in the IR, validation rules, and backend behavior before the adapter can accept it.

AST and IR describe different stages

An abstract syntax tree (AST) represents the structure of source code. It captures which expressions contain which others without retaining every punctuation detail. The parser has already resolved precedence, so you do not need to discover nested expressions with string replacement.

Use Python's standard-library ast parser for the Python expression. Use a JavaScript parser such as Babel for the JavaScript expression. A parser's exact node vocabulary matters: Babel documents where its AST differs from ESTree, the format used by many JavaScript tools. Python AST documentation, Babel parser documentation.

Here are abbreviated sketches, with location fields omitted. They show the relevant node relationships, not complete parser output.

Python AST sketch:

BoolOp(And, [
  Compare(Name("subtotal_cents"), [GtE], [Constant(5000)]),
  Name("is_member")
])

JavaScript AST sketch using Babel node names:

LogicalExpression("&&",
  BinaryExpression(">=",
    Identifier("subtotal_cents"), NumericLiteral(5000)),
  Identifier("is_member")
)

The Python AST stores a list of comparison operators because Python supports chained comparisons. The JavaScript sketch uses a binary expression. That difference belongs in the adapters, where the accepted forms can be checked explicitly.

The IR will record operations our rule engine understands, such as integer comparison and boolean conjunction. It can still be a tree. An AST is itself a kind of intermediate representation; the useful distinction here is between a source-oriented tree and the semantic contract shared by the backends.

You do not need bytecode or machine instructions to make that separation. LLVM's tutorial shows a more substantial AST-to-IR lowering stage. Our example uses a much smaller representation for a different purpose.

Give the shared IR a precise meaning

Five node kinds are enough:

  • Input: read one input named in the fixed schema.
  • Int: a non-negative integer constant within the accepted range.
  • Bool: a boolean constant.
  • GteInt: compare two integer values and return a boolean.
  • AndBool: evaluate the left boolean first; evaluate the right only if the left is true.

Int is a name in this example, not a promise to support every integer operation. Its range and available operations are part of the rule engine's contract.

Both source expressions lower to this JavaScript Object Notation (JSON) value:

{
  "kind": "AndBool",
  "left": {
    "kind": "GteInt",
    "left": {
      "kind": "Input",
      "name": "subtotal_cents"
    },
    "right": { "kind": "Int", "value": 5000 }
  },
  "right": { "kind": "Input", "name": "is_member" }
}

This is an expression IR: the host rule engine supplies the input and result boundary. Input types come from the fixed schema. The verifier knows that subtotal_cents is an integer and is_member is a boolean. An incoming IR node cannot declare itself trustworthy by adding a type field.

The JSON is ordinary data. What makes it an IR is the defined meaning of its nodes and the stages that consume it. Serializing two unrelated ASTs into JSON would not establish that shared meaning.

This representation also deliberately forgets things. It does not preserve comments, spacing, or whether the author added redundant parentheses. A formatter or tool promising minimal source edits would need to retain more source information.

Lower each AST through its own adapter

Lowering translates a representation into the operations of the next stage. For this example, the adapters have a short mapping:

  • Python Name and JavaScript Identifier become Input, provided the name exists in the schema.
  • Python Constant with type(value) is int, and JavaScript NumericLiteral, become Int after literal validation.
  • Python Constant with type(value) is bool, and JavaScript BooleanLiteral, become Bool.
  • A single Python Compare with GtE, or JavaScript BinaryExpression with >=, becomes GteInt.
  • A two-operand Python BoolOp with And, or JavaScript LogicalExpression with &&, becomes AndBool.

Each adapter recursively lowers the child expressions. It then passes the candidate tree to the shared verifier. The lowering is accepted only if verification succeeds. For example, is_member >= 5000 has a recognized shape but fails the integer operand requirement.

Require one complete expression. Do not parse a valid prefix and ignore trailing source. With Python, expression mode provides that boundary; with a JavaScript parser, check the chosen expression-parsing API's behavior or require a program containing exactly one expression statement.

For a longer Python conjunction, fold the operand list from the left into nested AndBool nodes. Thus a and b and c becomes AndBool(AndBool(a, b), c), matching the nesting of JavaScript's a && b && c. Each operand must still verify as a boolean, and neither adapter may reorder them.

Keep diagnostics connected to the source

When a node fails, the author needs to see which part of their rule caused the error. Keep a side table connecting IR node paths to the source file and parser location. For the shipping rule, the path right refers to is_member in either source file.

The semantic JSON can then be identical across languages while the diagnostic metadata differs. Compare the semantic tree in equivalence tests, and test source locations separately. Keep this tree and its side table immutable; a later transformation must rebuild the mappings or use stable node identifiers.

Do not assume location conventions match. Python AST column offsets count bytes in UTF-8 encoded text, so a visible character can occupy more than one position. Babel exposes its own location and offset fields. Retain each parser's convention until the diagnostic layer converts it for display. Python node locations, Babel parser location options.

Separate three kinds of validation

There are three distinct boundaries after parsing.

The language adapter checks accepted syntax. It rejects unknown names, calls, extra statements, unsupported operators, and literal forms outside the subset. For simplicity, require integer literals to use decimal digits only. Reject Python 5000.0 and JavaScript 5000.0, even though both runtimes can compare those values as numerically equal to 5000.

Retain the source text and check each literal's spelling through its parser span as well as checking its parsed value. Python's Constant alone does not distinguish 5000 from 5_000 or 0x1388. This lexical restriction keeps the example's integer literals unambiguous.

The IR verifier checks the common structure and types. It derives input types from the schema, checks constant values, requires two integer children for GteInt, and requires two boolean children for AndBool. It rejects unknown node kinds and a non-boolean root. If IR can arrive as external data, it must also check object shapes and resource limits; parser validation is not a substitute.

The runtime boundary checks actual input values. Python and JavaScript are dynamically typed. A verified Input node does not stop a caller from supplying "5000" as a string.

In Python, use an exact integer check for this contract: type(value) is int. A check based only on isinstance(value, int) also accepts booleans, because Python's bool is an integer subtype. In JavaScript, use Number.isInteger(value), the range checks, and typeof is_member === "boolean". Reject missing values. Python numeric types, ECMAScript Number.isInteger.

Validate every input before evaluation, then copy the accepted primitives into a fresh execution environment. For JavaScript, require the fields to be own properties of a decoded data record and canonicalize negative zero to zero. An invalid is_member must be rejected even when a low subtotal would make the conjunction skip reading it. Do not expose arbitrary host objects or property getters through this environment.

Where similar syntax stops meaning the same thing

Both source conjunctions work for our rule because both operands must be booleans. Python's and and JavaScript's && generally return an operand, and their truthiness rules differ.

Consider these expressions outside our subset:

[] and True  # returns []
[] && true   // returns true

Python treats an empty list as false. JavaScript treats an array object as true. Replacing one operator's spelling with the other does not preserve the result. Python boolean operations, ECMAScript logical operators, ECMAScript truthiness conversion.

AndBool avoids that ambiguity by excluding non-boolean operands. A broader IR could represent source-specific truthiness conversions, but then it would need to define those operations and implement them in each backend.

Numbers need a similar decision. Python integers have unlimited precision; JavaScript's Number type uses binary floating-point representation. Our non-negative range fits exactly in both, and the example performs comparison without arithmetic. Adding multiplication or division would require new decisions about result types, precision, and overflow. Python integer semantics, ECMAScript Number type.

The shared IR is therefore a contract for selected behavior. It does not erase the differences between the full languages.

Run the IR, or emit another language

Once the tree and inputs are validated, an interpreter can evaluate the five node kinds without knowing which parser produced them.

This JavaScript fragment shows the evaluator core. It assumes verified IR and a validated primitive environment; the verifier and input checks described above are separate stages.

function evaluate(node, inputs) {
  switch (node.kind) {
    case "Input":
      return inputs[node.name];
    case "Int":
    case "Bool":
      return node.value;
    case "GteInt":
      return evaluate(node.left, inputs)
        >= evaluate(node.right, inputs);
    case "AndBool":
      if (!evaluate(node.left, inputs)) return false;
      return evaluate(node.right, inputs);
    default:
      throw new Error("Unknown IR node");
  }
}

The explicit branch preserves short-circuit evaluation. In this pure subset, skipping a validated input read has no externally visible side effect. Still, the evaluator should follow the IR's defined evaluation order. If calls are added later, eagerly evaluating both operands could trigger work that the source expression would skip.

An interpreter executes the representation. A transpiler, or source-to-source compiler, emits source code. To add one here, write a backend that generates JavaScript from the verified IR.

Its rules are small: emit validated input names, emit constants, wrap each GteInt in parentheses with >=, and wrap each AndBool with &&. For our JSON tree, it produces:

((subtotal_cents >= 5000) && is_member)

The Python and JavaScript frontends can both use this same emitter. The Python path now translates an accepted Python expression into JavaScript source. The JavaScript path rewrites an accepted JavaScript expression through the same contract.

The generated expression must run behind the same input validation boundary. Otherwise JavaScript coercion can reappear at execution time. For larger outputs, use a target AST and a generator instead of stitching together arbitrary source fragments. Babel's generator is one example of an AST-to-source tool.

The pipeline has two source-specific entry paths:

  1. Python expression, Python parser, Python adapter, common IR.
  2. JavaScript expression, JavaScript parser, JavaScript adapter, common IR.

Both then pass through the shared IR verifier. The verified result can go to the interpreter or the JavaScript emitter. Runtime input validation applies to either execution path.

Test the boundary as well as the happy path

A few successful evaluations do not prove that lowering is correct. Test each responsibility separately.

Frontend fixtures

Assert that the two shipping expressions produce the exact JSON tree shown above. Verify both parser mappings, grouping, literal restrictions, and source diagnostics. Reject unknown names, calls, arrays, chained comparisons, and extra statements.

Verifier fixtures

Construct invalid IR directly. A GteInt with a boolean child must fail, even if neither frontend currently generates it. Test unknown nodes, malformed constants, and a root that returns an integer. Also check that Python's True >= 5000 fails verification after literal lowering preserves True as Bool.

Runtime fixtures

Exercise the threshold and the input contract:

  • Subtotal 0, member false: expect false.
  • Subtotal 4999, member true: expect false.
  • Subtotal 5000, member false: expect false.
  • Subtotal 5000, member true: expect true.
  • Subtotal 2147483647, member true: expect true.

Also reject a negative subtotal, a fraction, 2147483648, a string, a missing field, and a boolean supplied as the subtotal. These cases check the domain where the equivalence claim applies.

Cross-runtime checks

For trusted fixtures inside the accepted subset, compare the Python expression, JavaScript expression, IR interpreter, and emitted JavaScript result against the same expected value. Keep native execution limited to your test fixtures; evaluating arbitrary submitted source would bypass the rule engine's restrictions.

Evaluation-order checks

Instrument IR node visits and verify that the right subtree is skipped when the left result is false. Output equality alone cannot reveal that distinction in this pure example.

This evidence establishes agreement for the tested cases. It does not prove equivalence for every expression the adapters might accept. Add fixtures when the subset grows, especially where a new node introduces effects, coercion, or different error behavior.

Choose the boundary your tool actually needs

A shared IR earns its place when multiple source languages need the same validation, execution, or output behavior. The adapters absorb source-specific details; the common stages operate on an explicit contract.

For a one-language rename or formatting tool, an AST transformation may already be sufficient. A common AST can also work when the required transformations concern shared syntax. Creating a separate semantic IR is worthwhile when it gives downstream code a smaller, stable set of operations to understand.

If the shipping rules eventually need functions, effects, or several execution targets, grow the representation around those requirements. The LLVM project's Toy tutorial on emitting an IR offers a next step: it shows how a language-specific representation can preserve useful information for later lowering.

The same discipline helps when using coding agents without giving up architectural control: define what a transformation may change and which checks must hold before accepting it.

For a first implementation, keep the two source expressions, the one shared tree, and the rejection fixtures together. When someone proposes another language feature, require an answer to three questions: what does it mean in the IR, which inputs does it accept, and how will every backend preserve that meaning?

All articles