Inside the JavaScript Printer: 10,000 Lines of Layout Wisdom
Prerequisites
- ›Article 1: Architecture and Formatting Pipeline
- ›Article 2: Document IR and Layout Algorithm
- ›Article 3: Plugin System and Language Architecture
Inside the JavaScript Printer: 10,000 Lines of Layout Wisdom
The JavaScript printer is Prettier's most complex component. It handles JavaScript, TypeScript, Flow, JSX, Angular templates, and JSON — all through a single print() entry point that dispatches to ~75 specialized modules. Each module encodes hard-won knowledge about how developers expect code to be formatted. In this article, we'll explore the dispatch architecture, the AstPath traversal system, and three case studies that illustrate the formatting challenges that required hundreds of lines of carefully tuned heuristics.
The Printer Dispatch Chain
The internal printWithoutParentheses() function in src/language-js/print/index.js#L22-L35 delegates to sub-printers in a specific order:
for (const printer of [
printAngular,
printJsx,
printFlow,
printTypescript,
printEstree,
]) {
const doc = printer(path, options, print, args);
if (doc !== undefined) {
return doc;
}
}
The first sub-printer to return a non-undefined result wins. This chain establishes a priority order: Angular-specific nodes are handled before JSX, JSX before Flow type annotations, Flow before TypeScript, and vanilla ESTree nodes last.
flowchart TD
NODE["AST Node"] --> ANG{"Angular<br/>node type?"}
ANG -->|yes| ANGULAR["printAngular()"]
ANG -->|no| JSX{"JSX<br/>node type?"}
JSX -->|yes| JSXP["printJsx()"]
JSX -->|no| FLOW{"Flow<br/>node type?"}
FLOW -->|yes| FLOWP["printFlow()"]
FLOW -->|no| TS{"TypeScript<br/>node type?"}
TS -->|yes| TSP["printTypescript()"]
TS -->|no| EST["printEstree()"]
The printEstree sub-printer at src/language-js/print/estree.js is itself a large dispatch table, importing from over 30 specialized modules — one for each family of AST node types (arrays, arrow functions, assignments, binary expressions, call expressions, classes, etc.).
After printWithoutParentheses returns a doc, the outer print() function at src/language-js/print/index.js#L60-L97 handles three cross-cutting concerns: IIFE comment attachment, decorator printing, and parentheses insertion.
The createTypeCheckFunction pattern, used throughout the JS printer, creates efficient node-type matchers. Instead of checking node.type === "A" || node.type === "B" repeatedly, it creates a Set-based function once and reuses it.
AstPath: Stack-Based Tree Traversal
As discussed in Article 1, the AstPath class at src/common/ast-path.js is the JS printer's primary interface for understanding context. Let's look deeper at its internals.
The path maintains a single mutable stack array. The stack alternates between keys/indices and values:
[rootNode, "body", bodyArray, 0, firstStatement, "expression", exprNode]
The .call() method at line 126 is how printers traverse into children:
call(callback, ...names) {
const { stack } = this;
const { length } = stack;
let value = stack.at(-1);
for (const name of names) {
value = value?.[name];
stack.push(name, value);
}
try { return callback(this); }
finally { stack.length = length; }
}
This is elegant: push keys and values onto the stack, run the callback, then truncate the stack back to its original length in the finally block. No allocations, no copying — just mutating and restoring a single array.
sequenceDiagram
participant Printer
participant Path as AstPath
participant Stack
Note over Stack: [..., "expression", CallExpression]
Printer->>Path: path.call(print, "callee")
Path->>Stack: push("callee", calleeNode)
Note over Stack: [..., "expression", CallExpression, "callee", Identifier]
Path->>Printer: callback(this)
Printer->>Path: path.node → Identifier
Printer->>Path: path.parent → CallExpression
Path->>Stack: stack.length = originalLength
Note over Stack: [..., "expression", CallExpression]
The accessor API makes context-dependent formatting natural. The match() method at line 203 enables pattern matching on the ancestor chain — "is my parent a call expression and my grandparent an expression statement?" — with a single call.
Tip: When writing a Prettier plugin, prefer
path.match()over manually walkingpath.parentandpath.grandparent. Thematch()method handles array indices transparently and is more readable for complex ancestor patterns.
Parentheses Insertion
One of the JS printer's trickiest responsibilities is deciding when to add parentheses. The main logic lives in src/language-js/parentheses/needs-parentheses.js.
The approach is conservative: parentheses are added based on the combination of the current node's type, the parent node's type, and the key under which the current node appears. For example, an AssignmentExpression needs parentheses when it appears as the test of a ternary, but not when it appears as the right side of another assignment.
flowchart TD
NP["needsParentheses(path, options)"] --> ROOT{"Is root?"}
ROOT -->|yes| NO1["return false"]
ROOT -->|no| HTML{"HTML interpolation<br/>edge case?"}
HTML -->|yes| YES1["return true"]
HTML -->|no| STMT{"Is statement?"}
STMT -->|yes| NO2["return false"]
STMT -->|no| IDENT{"Is Identifier?"}
IDENT -->|yes| ID_CHECK["Check identifier-specific rules"]
IDENT -->|no| PARENT["parentNeedsParentheses:<br/>check (nodeType, parentType, key)"]
The separation of concerns here is clean: print/index.js calls needsParentheses after getting the doc from the sub-printer. If parens are needed, it wraps the doc with "(" and ")". The sub-printers never think about parentheses — they just format the node's content.
Case Study: Ternary Expressions
Ternary expressions (a ? b : c) are deceptively hard to format. The module at src/language-js/print/ternary.js handles multiple layout strategies:
Flat layout: When the entire ternary fits on one line:
const x = cond ? consequent : alternate;
Broken layout: When it doesn't fit:
const x = cond
? consequent
: alternate;
Nested ternary chains: When ternaries are nested:
const x = cond1
? value1
: cond2
? value2
: defaultValue;
The complexity comes from interactions. Should the closing paren break to keep the method chain right after the ternary? The shouldBreakClosingParen function (line 46) checks if the parent is a member expression:
// (a
// ? b
// : c
// ).call()
function shouldBreakClosingParen(node, parent) {
return (
(isMemberExpression(parent) || ...) && !parent.computed
);
}
There's also multiline block comment handling, JSX special cases, and interactions with the experimentalTernaries flag. This single node type requires its own module plus a separate ternary-old.js for the legacy formatting style.
Case Study: Call Arguments Grouping
The call arguments module at src/language-js/print/call-arguments.js implements one of Prettier's most impactful heuristics: last-argument expansion.
When the last argument to a function call is a "expandable" expression (function, arrow function, object literal, array literal), Prettier tries to keep it expanded on the same line:
// Instead of this (all args on separate lines):
foo(
arg1,
arg2,
{
key: value,
},
);
// Prettier prefers this (last arg stays expanded):
foo(arg1, arg2, {
key: value,
});
This is what makes .then(result => { ... }) and React component trees look natural. But implementing it requires checking dozens of conditions:
- Is the last argument a function/arrow/object/array/template literal?
- Does the function call have type parameters?
- Are there comments between arguments?
- Is this a special function like
require(),test(), ordescribe()? - Is this a curried call (
foo(a)(b))?
The module uses conditionalGroup (from Article 2) to provide multiple layout alternatives: the all-flat version, the last-arg-expanded version, and the all-broken version. The layout algorithm tries each and picks the most compact that fits.
flowchart TD
ARGS["printCallArguments(path, options, print)"] --> EMPTY{"0 args?"}
EMPTY -->|yes| PARENS["Return '()'"]
EMPTY -->|no| SPECIAL{"Special case?<br/>(require, test, etc.)"}
SPECIAL -->|yes| SIMPLE["Simple formatting"]
SPECIAL -->|no| EXPAND{"Last arg<br/>expandable?"}
EXPAND -->|no| STANDARD["Standard formatting:<br/>group with indent"]
EXPAND -->|yes| TRY["conditionalGroup:<br/>1. All flat<br/>2. Last arg expanded<br/>3. All broken"]
Case Study: Member Chain Formatting
Method chains like .map().filter().reduce() are another formatting challenge. The module at src/language-js/print/member-chain.js implements a multi-phase algorithm:
Phase 1: Linearize. The nested AST (CallExpression(MemberExpression(CallExpression(MemberExpression(...))))) is flattened into a linear list of chain elements.
Phase 2: Group. Elements are grouped by "semantic boundaries." A new group starts when the expression type changes (e.g., from property access to method call) or at natural boundaries.
Phase 3: Format. Each group is printed with appropriate line breaks. Short chains might stay on one line; long chains get one call per line.
The heuristics consider:
- Whether the chain is an expression statement (standalone line) or nested
- The length of the first element (short names like
_vs long paths) - Whether arguments contain complex expressions
- Whether there are comments anywhere in the chain
flowchart LR
AST["Nested AST:<br/>CallExpr(MemberExpr(CallExpr(...)))"] --> LINEAR["Phase 1:<br/>Linearize to flat list"]
LINEAR --> GROUP["Phase 2:<br/>Group by semantic boundaries"]
GROUP --> FORMAT["Phase 3:<br/>Format each group with<br/>conditional breaks"]
FORMAT --> DOC["Doc IR"]
The end result is the formatting developers expect:
// Short chain stays flat
arr.map(fn).filter(fn)
// Long chain breaks per call
arr
.map(x => x + 1)
.filter(x => x > 10)
.reduce((acc, x) => acc + x, 0)
JS-Specific Comment Handling and Special Cases
The JS printer has its own comment handling beyond the core comment system (which we'll explore in Article 5). The comment handlers in src/language-js/comments/handle-comments.js address language-specific attachment challenges:
- If statements: Comments between
if (cond)and{need special treatment - For loops: Comments inside
for (;;)clauses - Switch cases: Comments between
caseand the next statement - Class bodies: Comments between class members
- Import/export: Comments in import specifier lists
The willPrintOwnComments escape hatch (referenced in src/language-js/printers.js) lets JSX elements and union type annotations handle their own comment printing. JSX wraps comments in {/* ... */} blocks, which requires different handling than the generic leading/trailing comment system.
Tip: If you're debugging unexpected JS formatting, check three things in order: (1) the parentheses logic — is an unexpected wrapping causing breaks? (2) the comment attachment — did a comment get attached to the wrong node? (3) the specific sub-printer's heuristics — is a grouping or expansion rule triggering unexpectedly?
What's Next
We've seen how the JS printer handles the complexity of a real-world language. But we kept referring to "comment attachment" without explaining how it works. In Article 5, we'll tackle what many consider the hardest problem in code formatting: placing comments correctly when the code around them has been completely reformatted.