TypeScript Compiler Output and Build Target Optimization
TypeScript Compiler Output and Build Target Optimization
The Symptom
The e-commerce platform’s TypeScript compiles with target: "es5". The output JavaScript includes helper functions for every modern language feature: __awaiter for async/await, __generator for generators, __spreadArray for spread syntax, __assign for object spread. These helpers add kilobytes to every file that uses modern TypeScript syntax.
The source code uses async/await in 47 files. Each file’s compiled output includes the __awaiter helper inlined or imported from tslib. On the e-commerce platform, tslib contributes 7.2KB gzipped to the bundle. The __awaiter and __generator helpers alone account for 3.1KB of that.
Every modern browser supports async/await natively. The 3.1KB of helper code does nothing except convert syntax the browser already understands back into syntax the browser also understands, but slower.
The Cause
The TypeScript target option determines which JavaScript language features the compiler emits natively and which it down-levels to helper functions. With target: "es5", the compiler assumes the runtime does not support any feature introduced after ES5. Every async function, every for...of loop, every template literal, every destructuring assignment gets compiled to ES5 equivalents with helper functions.
With target: "es2022", the compiler emits these features as-is. No helpers needed. The output is smaller and faster because the browser’s native implementations are optimized in ways that polyfill code cannot match.
The risk of raising the target is shipping syntax that older browsers cannot parse. A browser that does not understand async will throw a syntax error on the entire script, not just the async function. This is a hard failure, not a graceful degradation.
The mitigation is browser support data. The e-commerce platform’s analytics show:
| Browser | Traffic Share | ES2022 Support |
|---|---|---|
| Chrome 90+ | 62% | Yes |
| Safari 15+ | 18% | Yes |
| Firefox 90+ | 8% | Yes |
| Edge 90+ | 7% | Yes |
| Other | 5% | Partial |
95% of traffic comes from browsers that fully support ES2022. The 5% “Other” includes bots, embedded WebViews, and browsers older than 3 versions back. Serving ES5 to 100% of users to accommodate 5% wastes bandwidth for the 95%.
The Baseline
Build output size comparison with the e-commerce platform’s codebase:
# ES5 target
tsc --target es5 --module esnext --outDir dist-es5
# 1,847 KB total output (unminified)
# ES2022 target
tsc --target es2022 --module esnext --outDir dist-es2022
# 1,312 KB total output (unminified)
535KB difference in unminified output. After minification and gzip:
| Target | Gzipped Size | Parse Time (Moto G) |
|---|---|---|
| ES5 | 420 kB | 1,200ms |
| ES2022 | 348 kB | 940ms |
| Delta | -72 kB | -260ms |
The 260ms parse time reduction comes from two sources: smaller file size (less code to parse) and simpler code structure (native async/await parses faster than the state machine that __awaiter compiles to).
The Fix
// tsconfig.json
{
"compilerOptions": {
"target": "es2022",
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"jsx": "react-jsx",
"declaration": false,
"sourceMap": true,
"isolatedModules": true,
"verbatimModuleSyntax": true
}
}
Key settings and their performance implications:
target: "es2022": Eliminates helper functions for async/await, optional chaining, nullish coalescing, logical assignment, class fields, top-level await, and Array.prototype.at(). All of these are supported in the browser matrix shown above.
module: "esnext": Emits ES module import/export statements, which Vite and Webpack require for tree shaking. Using "commonjs" here would break tree shaking entirely.
moduleResolution: "bundler": Tells TypeScript to resolve modules the way Vite does, including package.json exports field support. Without this, TypeScript might resolve a different entry point than Vite, causing type errors or runtime mismatches.
isolatedModules: true: Ensures each file can be compiled independently, which is required by Vite’s esbuild-based transform. Without this, some TypeScript patterns (const enums, namespace merging) would compile correctly with tsc but fail with Vite.
verbatimModuleSyntax: true: Prevents TypeScript from eliding import type statements in ways that confuse the bundler. With this setting, import type { Foo } is always removed (no runtime import), and import { Foo } is always preserved (the bundler decides if it is used).
For the Vite build configuration, align the build target:
// vite.config.ts
export default defineConfig({
build: {
target: "es2022",
// ...
},
esbuild: {
target: "es2022",
},
});
The Vite build.target controls the output of the production build. The esbuild.target controls the development server’s transform. Both should match tsconfig.json’s target to avoid surprises where development and production behave differently.
The Proof
After changing from es5 to es2022:
| Metric | Before (ES5) | After (ES2022) | Delta |
|---|---|---|---|
| Total JS gzipped | 420 kB | 348 kB | -72 kB (-17%) |
| Main thread parse | 1,200ms | 940ms | -260ms |
| LCP (Moto G, 4G) | 4.2s | 3.7s | -500ms |
| TBT | 1,800ms | 1,420ms | -380ms |
| tslib in bundle | 7.2 kB | 0 kB | -7.2 kB |
The 500ms LCP improvement comes from the combined effect of 260ms less parse time and 240ms earlier script evaluation, which unblocks the rendering of the LCP element sooner.
The tslib package is no longer needed and can be removed from package.json. Every __awaiter, __generator, __spreadArray, and __assign call is eliminated from the output.
The CI bundle size gate from Chapter 2 registers this as a baseline improvement. The new budget for total JavaScript drops from 350KB to 280KB, locking in the gain.
The Trade-off
The 5% of users on browsers that do not support ES2022 will see a broken page. Not a slow page. A broken page. The browser’s JavaScript parser will throw a SyntaxError on the first async keyword or optional chaining operator, and the entire script will fail to execute.
The options for handling this:
-
Accept the breakage: For the e-commerce platform, 5% of traffic on unsupported browsers translates to ~3% of revenue (these users convert at a lower rate regardless). The business decision to accept this loss in exchange for a 17% bundle size reduction for 95% of users is defensible, but it is a business decision, not a technical one.
-
Differential serving: Generate two builds (ES2022 and ES5) and serve the appropriate one based on the
User-Agentheader or the<script type="module">/<script nomodule>pattern. This doubles the build time and increases deployment complexity but serves both audiences. -
Progressive enhancement: Serve the ES2022 build to all users and provide a minimal, non-JavaScript experience for unsupported browsers. This only works if the application has meaningful server-rendered content.
The e-commerce platform chose option 1 after verifying that the 5% unsupported segment had a conversion rate 78% lower than supported browsers, indicating these were primarily bots, crawlers, and outdated embedded WebViews rather than paying customers.