Transpiling: TypeScript and JSX to JavaScript
Tokens, the syntax tree, transforms and code generation; stripping types vs checking them; the JSX runtimes; syntax lowering for older browsers; and source maps, each with a live demo.
In From source to bundle the transform stage was one box: TypeScript and JSX go in, JavaScript comes out. This page opens it. Babel, SWC, Oxc, esbuild and TypeScript's own compiler all work the same way: scan the text into tokens, parse the tokens into a syntax tree, transform the tree, and print it back out as JavaScript with a source map.
The first demo runs a small compiler written for this page, in your browser, on whatever you type. Its output was checked against TypeScript's. Everything labelled with a tool and version is that tool's real output.
Tokens, syntax trees and code generation
A compiler never works on text directly. The scanner (or lexer) cuts the characters into tokens: names, keywords, strings, punctuation. The parser arranges the tokens into an abstract syntax tree (AST) that records what the code means: this is a function, these are its parameters, this is its body. Transforms rewrite that tree, and code generation prints it.
- Whitespace and comments vanish at the first step, which is why output formatting is the printer's choice, not yours. Formatters such as Prettier parse your code and print the tree back the same way.
- What a token is depends on context: in
<p>Hi there</p>the scanner reads text, not two names, and/x/gis a regular expression only where an expression can start. This is why real scanners are driven by the parser. - Type annotations are ordinary nodes in the tree (
TSTypeAnnotation,TSTypeAliasDeclaration…). Transpiling TypeScript is mostly deleting them. - The tree is the interface every tool shares. ESLint rules, codemods, Babel plugins and the React Compiler all read and rewrite ASTs; astexplorer.net shows the tree each real parser builds.
- Unused imports that are only types are removed too: a per-file compiler can see that
ReactNodeis never used as a value in this file.
Try it: edit a component and watch every stage
Pick a token or a tree node to highlight the source text it came from.
The parser's output. Nodes marked type exist only for the type checker; the transform deletes them.
What this simplified compiler supports
- import and export (including import type and export type)
- const, let and var with destructuring; functions and arrow functions, async and await
- if/else, for…of, return and throw
- expressions: calls, members, ?., ??, operators, ternaries, templates, objects, arrays, spread, regex
- types: annotations, type and interface, generics, unions, tuples, function types, as, satisfies, !
- JSX: elements, fragments, attributes, spread props, expressions and text
Not supported: classes, enums, namespaces, decorators, switch, while, try/catch, function expressions, generators and comments in the output.
It was checked against TypeScript's own per-file transpile (react-jsx, target ESNext): for the samples on this page the output is the same apart from formatting.
Do: Read the tree of a snippet in astexplorer.net before writing a lint rule or codemod: you match nodes, not text.
Avoid: Rewriting code with regular expressions: they can't tell a string from a name, or a type from a value.
Read more:AST Explorer(opens in a new tab)Babel plugin handbook(opens in a new tab)TypeScript compiler notes(opens in a new tab)
Stripping types is not checking them
Babel, SWC, Oxc and esbuild compile one file at a time and never look at types. That is why they are fast, and why a type error never stops your dev server. Only a TypeScript program (tsc), which reads every file and its dependencies, can check types.
- Most TypeScript is erasable: delete the types and valid JavaScript is left. A few constructs (
enum,namespacewith values, constructor parameter properties) mean code must be generated, not just removed. - Node.js runs
.tsfiles directly since 22.18 and 23.6 by replacing types with spaces; it rejects non-erasable syntax unless you pass--experimental-transform-types. TypeScript 5.8'serasableSyntaxOnlyflags those constructs in your editor. isolatedModulesmakestscreport code that a per-file compiler can't handle correctly, such as re-exporting a type withoutexport type. Turn it on whenever another tool does the compiling.import typeandverbatimModuleSyntaxmake the intent explicit, so every tool removes exactly the same imports.
Try it: pick a construct and see what the real tools emit
Erasable: Every tool deletes them: parameter types, return types and variable types carry no runtime meaning.
function total(price: number, qty: number = 1): number {
return price * qty;
}
function total(price, qty = 1) {
return price * qty;
}
With erasableSyntaxOnly and isolatedModules on, the type checker says:
No errors.
A type error doesn't stop the transpiler
function shout(name: string) {
return name.toUpperCase() + "!";
}
console.log(shout(42));
function shout(name) {
return name.toUpperCase() + "!";
}
console.log(shout(42));
tsc --noEmit reports: TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
Runs it here, in your browser.
Do: Run
tsc --noEmitin CI and in your editor; enableisolatedModulesand, for new code,erasableSyntaxOnly.Avoid: Expecting the dev server or bundler to catch type errors, or using
const enumacross files with a per-file compiler.
Read more:TypeScript: isolatedModules(opens in a new tab)TypeScript: erasableSyntaxOnly(opens in a new tab)Node.js: Running TypeScript natively(opens in a new tab)
What JSX becomes
JSX is not HTML and not part of JavaScript: the compiler turns each element into a function call that returns a plain object describing it. Which function depends on the JSX runtime the compiler is set to.
- The classic runtime calls
React.createElement(type, props, ...children), which only works ifReactis in scope. That is why every component file used to start withimport React from "react". - The automatic runtime (React 17 and later, the default in every current tool) imports
jsxandjsxsfromreact/jsx-runtimeitself. Children travel inside props, and the key is a separate argument. - Lowercase tags become strings (
"div"); capitalized ones stay variables (Card). That is why components must start with a capital letter. - Text between tags is trimmed line by line and joined with single spaces, so indenting JSX never adds stray whitespace to the page.
- What these calls return, and what React does with it, is the subject of Inside React.
Try it: switch runtimes and examples
import { jsx as _jsx } from "react/jsx-runtime";
export function List({ items }) {
return (_jsx("ul", {
children: items.map((item) => (_jsx("li", { children: item.title }, item.id)))
}));
}
Automatic: the compiler adds the import from react/jsx-runtime itself. The key is passed separately, children go into props, and jsxs marks a static list of several children so React doesn't warn about missing keys. A key after a spread can't be pulled out safely, so it falls back to createElement.
Do: Use the automatic runtime (
"jsx": "react-jsx"in tsconfig) and drop unusedReactimports.Avoid: Putting the key after a {...spread}: the compiler has to fall back to the slower createElement path.
Read more:React: Introducing the new JSX transform(opens in a new tab)TypeScript: jsx option(opens in a new tab)
Targets: lowering syntax, polyfilling APIs
A compiler's target is the oldest JavaScript it may emit. Syntax newer than the target is lowered: rewritten with older syntax that does the same thing. Bundlers usually derive the target from your browserslist query.
- Lowering costs bytes and speed:
async/awaitfor ES2015 becomes a generator-driven helper, anda?.bbecomes temporary variables andnullchecks. Every modern browser supports ES2020+, so targeting older versions mostly ships dead weight. - Some features can't be lowered.
Array.prototype.at,structuredCloneorPromise.withResolversare functions the browser provides, not syntax: the compiler leaves them alone, and old browsers need a polyfill that defines them at runtime. - Helpers such as
__awaiterare inlined in every file that needs them unless you import them from a shared package (tslib,@babel/runtime,@swc/helpers).
Try it: pick a feature and a target
Rewritten: ES2015 predates this syntax (ES2020), so the compiler expresses it with older code.
const city = user?.address?.city ?? "unknown";
var _a, _b;
const city = (_b = (_a = user === null || user === void 0 ? void 0 : user.address) === null || _a === void 0 ? void 0 : _a.city) !== null && _b !== void 0 ? _b : "unknown";
Output size for each target, measured in your browser:
- ES2015measuring…
- ES2017measuring…
- ES2020measuring…
- ESNextmeasuring…
Real output of TypeScript 5.9.3 with helpers inlined (importHelpers off), as a per-file transpile produces it.
Do: Set targets from real usage data (browserslist), and add polyfills only for the APIs you call.
Avoid: Targeting ES5 or ES2015 by habit: modern users download and run the lowered code too.
Read more:browserslist(opens in a new tab)web.dev: Serve modern code to modern browsers(opens in a new tab)core-js(opens in a new tab)
Source maps
After compiling, bundling and minifying, line 1 column 4,821 of a bundle means nothing to you. A source map is a JSON file that maps positions in the output back to files, lines and columns in your source, so the developer tools and error trackers can show your code instead.
- The
mappingsstring holds one group per output line (separated by;) of segments (separated by,). Each segment is up to five numbers: output column, source file, source line, source column and name, each relative to the previous segment. - The numbers are base64 VLQ: 5 bits per character plus a continuation bit, sign in the lowest bit. Small relative numbers make most segments 4 characters long.
- The output links to its map with a
//# sourceMappingURL=comment (or theSourceMapheader).sourcesContentembeds the original files, so serving maps publicly also publishes your source. - Each tool in the chain produces a map, and the bundler composes them, so a minified line still points at your original
.tsx.
Try it: pick a segment and follow it back
The source, with the selected segment's original position marked:
type Props = { name: string };
export function Greeting({ name }: Props) {
const upper: string = name.toUpperCase();
return <h1 title={upper}>Hello, {name}!</h1>;
}
Greeting.js
import { jsxs as _jsxs } from "react/jsx-runtime";
export function Greeting({ name }) {
const upper = name.toUpperCase();
return _jsxs("h1", { title: upper, children: ["Hello, ", name, "!"] });
}
Pick a segment below to see where that part of the output came from.
19 segments, decoded back from the map's mappings string (right: each segment as written, relative to the one before):
{
"version": 3,
"file": "Greeting.js",
"sources": [
"Greeting.tsx"
],
"sourcesContent": [
"…the original file, so the developer tools can show it…"
],
"names": [
"Greeting",
"name",
"upper",
"toUpperCase"
],
"mappings": ";AAEA,OAAO,SAASA,SAAS,EAAEC;EACzB,MAAMC,QAAgB,KAAKC;EAC3B,OAAO,MAAC,MAAD,EAAI,OAAOD,iBAAX,CAAkB,WAAQD,MAAK"
}Do: Generate source maps for production builds and upload them to your error tracker.
Avoid: Serving maps with sourcesContent from a public server if your source is meant to stay private.
Read more:ECMA-426: Source map format(opens in a new tab)web.dev: Source maps(opens in a new tab)Source map visualizer(opens in a new tab)