Alex Vakhitov

software engineering5 min read

Translating Python to JavaScript with the ast module

By Alex Vakhitov

Green tree python coiled on a branch

Updated September 2026: the code now uses ast.Constant (the old ast.Num and ast.Str nodes were removed in Python 3.14), translates floor division and modulo correctly, escapes strings and handles comprehension filters. Tested with Python 3.13 and 3.14, and the output checked in Node.js.

Code translation has uses ranging from compiler design to migrating code between languages. In this post I show how to translate a small subset of Python into JavaScript using Python's built-in ast module.

What is an AST?

An abstract syntax tree (AST) is a tree-shaped representation of source code that makes it easier to analyse and change programmatically. ASTs are widely used in compilers, linters and code translation tools. Python's standard library includes the ast module, which parses Python source code into an AST.

For example, ast.parse("x * x") produces a BinOp node whose left and right are Name nodes and whose op is Mult. A translator walks this tree and emits the equivalent code in the target language.

The translator

The class below, PyToJsTranslator, subclasses ast.NodeVisitor. For each node type it supports there's a visit_<NodeType> method that returns a string of JavaScript. Anything it doesn't support raises NotImplementedError rather than silently producing wrong code.

import ast
import json


class PyToJsTranslator(ast.NodeVisitor):
    """Translate a small subset of Python into JavaScript."""

    BINARY_OPERATORS = {
        ast.Add: "+",
        ast.Sub: "-",
        ast.Mult: "*",
        ast.Div: "/",
        ast.Pow: "**",
    }

    COMPARISON_OPERATORS = {
        ast.Eq: "===",
        ast.NotEq: "!==",
        ast.Lt: "<",
        ast.LtE: "<=",
        ast.Gt: ">",
        ast.GtE: ">=",
    }

    def translate(self, python_code):
        return self.visit(ast.parse(python_code))

    def generic_visit(self, node):
        # Fail loudly instead of silently dropping code we don't understand.
        raise NotImplementedError(f"Unsupported syntax: {type(node).__name__}")

    def visit_Module(self, node):
        return "\n".join(self.visit(statement) for statement in node.body)

    def visit_FunctionDef(self, node):
        args = ", ".join(arg.arg for arg in node.args.args)
        body = "\n".join("    " + self.visit(statement) for statement in node.body)
        return f"function {node.name}({args}) {{\n{body}\n}}"

    def visit_Return(self, node):
        return f"return {self.visit(node.value)};"

    def visit_Expr(self, node):
        return f"{self.visit(node.value)};"

    def visit_Name(self, node):
        return node.id

    def visit_Constant(self, node):
        # ast.Constant replaced ast.Num and ast.Str (removed in Python 3.14).
        if node.value is None:
            return "null"
        if isinstance(node.value, bool):
            return "true" if node.value else "false"
        if isinstance(node.value, (int, float, str)):
            return json.dumps(node.value)  # json.dumps also escapes strings
        raise NotImplementedError(f"Unsupported constant: {node.value!r}")

    def visit_BinOp(self, node):
        left, right = self.visit(node.left), self.visit(node.right)
        if isinstance(node.op, ast.FloorDiv):
            # `//` starts a comment in JavaScript.
            return f"Math.floor({left} / {right})"
        if isinstance(node.op, ast.Mod):
            # Python's % takes the sign of the divisor; JavaScript's takes the sign of the dividend.
            return f"((({left}) % ({right})) + ({right})) % ({right})"
        op = self.BINARY_OPERATORS.get(type(node.op))
        if op is None:
            raise NotImplementedError(f"Unsupported operator: {type(node.op).__name__}")
        return f"({left} {op} {right})"

    def visit_Compare(self, node):
        if len(node.ops) != 1:
            raise NotImplementedError("Chained comparisons such as a < b < c")
        op = self.COMPARISON_OPERATORS.get(type(node.ops[0]))
        if op is None:
            raise NotImplementedError(f"Unsupported comparison: {type(node.ops[0]).__name__}")
        return f"{self.visit(node.left)} {op} {self.visit(node.comparators[0])}"

    def visit_Call(self, node):
        args = ", ".join(self.visit(arg) for arg in node.args)
        return f"{self.visit(node.func)}({args})"

    def visit_ListComp(self, node):
        if len(node.generators) != 1:
            raise NotImplementedError("Comprehensions with more than one 'for'")
        generator = node.generators[0]
        if not isinstance(generator.target, ast.Name):
            raise NotImplementedError("Comprehension targets other than a single name")
        name = generator.target.id
        result = self.visit(generator.iter)
        for condition in generator.ifs:
            result += f".filter(({name}) => {self.visit(condition)})"
        return result + f".map(({name}) => {self.visit(node.elt)})"

A few details matter more than they look:

  • Constants. Since Python 3.8, numbers, strings, True, False and None all parse to ast.Constant. The old ast.Num and ast.Str classes were deprecated then and removed in Python 3.14, so code that uses them now fails. json.dumps produces valid JavaScript literals and escapes quotes in strings.
  • Floor division. Python's // starts a comment in JavaScript, so a // b becomes Math.floor(a / b).
  • Modulo. Python's % takes the sign of the divisor and JavaScript's takes the sign of the dividend, so -7 % 2 is 1 in Python and -1 in JavaScript. The translation adds the divisor and takes the remainder again to match Python.
  • Comprehensions. A list comprehension becomes .map(), and each if clause becomes a .filter() before it.

Usage

from py_to_js import PyToJsTranslator

python_code = '''
def square_elements(numbers):
    return [x * x for x in numbers]

def even_squares(numbers):
    return [x ** 2 for x in numbers if x % 2 == 0]

def split_bill(total, people):
    return total // people
'''

print(PyToJsTranslator().translate(python_code))

This prints:

function square_elements(numbers) {
    return numbers.map((x) => (x * x));
}
function even_squares(numbers) {
    return numbers.filter((x) => (((x) % (2)) + (2)) % (2) === 0).map((x) => (x ** 2));
}
function split_bill(total, people) {
    return Math.floor(total / people);
}

Running the generated functions in Node.js gives the same results as the Python originals: square_elements([1, 2, 3]) returns [1, 4, 9], even_squares([1, 2, 3, 4, -2]) returns [4, 16, 4], and split_bill(100, 3) and split_bill(-7, 2) return 33 and -4.

Strings are escaped properly too: return "It's " + name becomes return ("It's " + name);. A while loop, which the translator doesn't handle, raises NotImplementedError: Unsupported syntax: While.

Limitations

This is a teaching example, not a production tool. In particular:

  • It supports only a small subset of Python: functions, return, names, constants, arithmetic, single comparisons, calls and simple list comprehensions. No loops, if statements, assignments, classes or imports.
  • Python's == compares values, and JavaScript's === compares identity for objects and arrays, so [1] == [1] is True in Python but [1] === [1] is false in JavaScript. The mapping is only safe for numbers, strings and booleans.
  • Python integers have arbitrary precision; JavaScript numbers are 64-bit floats unless you use BigInt.
  • Function calls are copied as they are, so Python built-ins such as len() or print() would need mapping to their JavaScript equivalents.

Summary

Python's ast module makes the parsing part of translation easy: you get a clean tree and only need to decide what to emit for each node. The hard part is semantics. Operators that look the same, such as % and ==, can behave differently in the two languages, and that's where most of the care goes. The same tree-walking technique is useful for analysis too; I use it to measure cyclomatic complexity.